Pulse/internal/api/resources.go

2592 lines
76 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// ResourceHandlers provides HTTP handlers for the unified resource API.
type ResourceHandlers struct {
cfg *config.Config
storeMu sync.Mutex
stores map[string]unified.ResourceStore
cacheMu sync.Mutex
registryCache map[string]registryCacheEntry
supplementalMu sync.RWMutex
supplementalRecords map[unified.DataSource]SupplementalRecordsProvider
stateProvider SnapshotProvider
tenantStateProvider TenantStateProvider
}
type registrySeed struct {
snapshot models.StateSnapshot
resources []unified.Resource
lastUpdate time.Time
unifiedSource bool
}
// SupplementalRecordsProvider provides out-of-band ingest records for a specific source.
type SupplementalRecordsProvider interface {
GetCurrentRecords() []unified.IngestRecord
}
// TenantSupplementalRecordsProvider is an optional interface for providers that can scope
// supplemental records to a specific organization. This prevents cross-tenant leakage when
// Pulse runs in multi-tenant mode.
//
// Providers that do not implement this interface will be treated as "global"/legacy and
// their records will be ingested into every tenant registry.
type TenantSupplementalRecordsProvider interface {
GetCurrentRecordsForOrg(orgID string) []unified.IngestRecord
}
// SupplementalSnapshotSourceOwner is an optional interface for providers that
// own source-native resource ingestion and want matching legacy snapshot slices
// suppressed during registry construction.
type SupplementalSnapshotSourceOwner interface {
SnapshotOwnedSources() []unified.DataSource
}
// TenantSupplementalSnapshotSourceOwner is the tenant-aware variant of
// SupplementalSnapshotSourceOwner.
type TenantSupplementalSnapshotSourceOwner interface {
SnapshotOwnedSourcesForOrg(orgID string) []unified.DataSource
}
// NewResourceHandlers creates a new ResourceHandlers.
func NewResourceHandlers(cfg *config.Config) *ResourceHandlers {
return &ResourceHandlers{
cfg: cfg,
stores: make(map[string]unified.ResourceStore),
registryCache: make(map[string]registryCacheEntry),
supplementalRecords: make(map[unified.DataSource]SupplementalRecordsProvider),
}
}
// SetStateProvider sets the state provider for on-demand population.
func (h *ResourceHandlers) SetStateProvider(provider SnapshotProvider) {
h.stateProvider = provider
}
// SetTenantStateProvider sets the tenant-aware provider.
func (h *ResourceHandlers) SetTenantStateProvider(provider TenantStateProvider) {
h.tenantStateProvider = provider
}
// SetSupplementalRecordsProvider configures additional records for a source.
func (h *ResourceHandlers) SetSupplementalRecordsProvider(source unified.DataSource, provider SupplementalRecordsProvider) {
h.supplementalMu.Lock()
if h.supplementalRecords == nil {
h.supplementalRecords = make(map[unified.DataSource]SupplementalRecordsProvider)
}
if provider == nil {
delete(h.supplementalRecords, source)
} else {
h.supplementalRecords[source] = provider
}
h.supplementalMu.Unlock()
// Provider changes alter ingestion inputs, so clear all cached registries.
h.cacheMu.Lock()
h.registryCache = make(map[string]registryCacheEntry)
h.cacheMu.Unlock()
}
// HandleListResources handles GET /api/resources.
func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
allResources := registry.List()
resources := allResources
if unsupported := unsupportedResourceTypeFilterTokens(r.URL.Query().Get("type")); len(unsupported) > 0 {
http.Error(w, "unsupported type filter token(s): "+strings.Join(unsupported, ", "), http.StatusBadRequest)
return
}
filters := parseListFilters(r)
resources = applyFilters(resources, filters)
applySorting(resources, filters.sortField, filters.sortOrder)
paged, meta := paginate(resources, filters.page, filters.limit)
attachDiscoveryTargets(paged)
attachMetricsTargets(paged, registry)
paged = unified.RefreshCanonicalMetadataSlice(paged)
pruneResourcesForListResponse(paged)
// Build aggregations: use registry.Stats() for Total/ByStatus/BySource (unfiltered,
// no conversion needed), but recompute ByType from the full registry list so keys
// match the canonical REST resource contract.
stats := registry.Stats()
stats.ByType = computeResourceContractByType(allResources)
applyResourceContractTypes(paged)
response := EmptyResourcesResponse()
response.Data = paged
response.Meta = meta
response.Aggregations = stats
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response.NormalizeCollections())
}
// HandleStorageSummary handles GET /api/resources/storage-summary.
func (h *ResourceHandlers) HandleStorageSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resources := registry.List()
filters := parseListFilters(r)
storageSubjects := make([]unified.Resource, 0, len(resources))
for _, resource := range resources {
if !isStorageSummaryResource(resource) {
continue
}
storageSubjects = append(storageSubjects, resource)
}
storageSubjects = applyFilters(storageSubjects, filters)
response := buildStorageSummaryResponse(storageSubjects)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDashboardSummary handles GET /api/resources/dashboard-summary.
func (h *ResourceHandlers) HandleDashboardSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resources := unified.RefreshCanonicalMetadataSlice(registry.List())
attachMetricsTargets(resources, registry)
applyResourceContractTypes(resources)
response := buildDashboardOverviewSummaryResponse(resources)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleStorageIncidents handles GET /api/resources/storage-incidents.
func (h *ResourceHandlers) HandleStorageIncidents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resources := registry.List()
filters := parseListFilters(r)
incidentSubjects := make([]unified.Resource, 0, len(resources))
for _, resource := range resources {
if !isStorageSummaryResource(resource) || resource.IncidentCount == 0 {
continue
}
incidentSubjects = append(incidentSubjects, resource)
}
incidentSubjects = applyFilters(incidentSubjects, filters)
response := buildStorageIncidentsResponse(incidentSubjects)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// pruneResourcesForListResponse removes heavy, platform-specific fields that would bloat
// the list response. Detail drawers can fetch full payloads via GET /api/resources/{id}.
func pruneResourcesForListResponse(resources []unified.Resource) {
for i := range resources {
pruneResourceForListResponse(&resources[i])
}
}
func pruneResourceForListResponse(resource *unified.Resource) {
if resource == nil {
return
}
// PMG domain stats can be very large; keep summary-only in list.
if resource.PMG != nil {
resource.PMG.RelayDomains = nil
resource.PMG.DomainStats = nil
resource.PMG.DomainStatsAsOf = time.Time{}
}
}
// HandleGetResource handles GET /api/resources/{id}.
func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resourceID := strings.TrimPrefix(r.URL.Path, "/api/resources/")
resourceID = strings.TrimSuffix(resourceID, "/")
resourceID = unified.CanonicalResourceID(resourceID)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
resource, ok := registry.Get(resourceID)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
resourceCopy := *resource
attachDiscoveryTarget(&resourceCopy)
attachMetricsTarget(&resourceCopy, registry)
unified.RefreshCanonicalMetadata(&resourceCopy)
resourceCopy.Type = resourceContractType(resourceCopy)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resourceCopy)
}
type resourceFacetCountsResponse = unified.ResourceFacetCounts
type resourceFacetBundleResponse struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts resourceFacetCountsResponse `json:"counts"`
}
// HandleResourceRoutes dispatches nested resource routes.
func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/facets") {
h.HandleGetResourceFacets(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/timeline") {
h.HandleGetResourceTimeline(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/children") {
h.HandleGetChildren(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/metrics") {
h.HandleGetMetrics(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/link") {
h.HandleLink(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/unlink") {
h.HandleUnlink(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/report-merge") {
h.HandleReportMerge(w, r)
return
}
h.HandleGetResource(w, r)
}
// HandleGetResourceFacets handles GET /api/resources/{id}/facets.
func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resourceID := strings.TrimPrefix(r.URL.Path, "/api/resources/")
resourceID = strings.TrimSuffix(resourceID, "/facets")
resourceID = strings.TrimSuffix(resourceID, "/")
resourceID = unified.CanonicalResourceID(resourceID)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
_, ok := registry.Get(resourceID)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
since := time.Time{}
if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
parsed, parseErr := time.Parse(time.RFC3339, raw)
if parseErr != nil {
http.Error(w, "Invalid since value", http.StatusBadRequest)
return
}
since = parsed.UTC()
}
limit := 25
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, parseErr := strconv.Atoi(raw)
if parseErr != nil || parsed <= 0 {
http.Error(w, "Invalid limit value", http.StatusBadRequest)
return
}
limit = parsed
}
filters, err := unified.ParseResourceChangeFilters(r.URL.Query()["kind"], r.URL.Query()["sourceType"], r.URL.Query()["sourceAdapter"])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
recentChanges, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
changeKindCounts, err := store.CountRecentChangesByKindFiltered(resourceID, since, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
sourceTypeCounts, err := store.CountRecentChangesBySourceTypeFiltered(resourceID, since, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
sourceAdapterCounts, err := store.CountRecentChangesBySourceAdapterFiltered(resourceID, since, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resourceFacetBundleResponse{
ResourceID: resourceID,
RecentChanges: recentChanges,
Counts: resourceFacetCountsResponse{
RecentChanges: changeCount,
RecentChangeKinds: changeKindCounts,
RecentChangeSourceTypes: sourceTypeCounts,
RecentChangeSourceAdapters: sourceAdapterCounts,
},
})
}
// HandleGetChildren handles GET /api/resources/{id}/children.
func (h *ResourceHandlers) HandleGetChildren(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
path = strings.TrimSuffix(path, "/children")
path = strings.TrimSuffix(path, "/")
path = unified.CanonicalResourceID(path)
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
children := registry.GetChildren(path)
children = unified.RefreshCanonicalMetadataSlice(children)
applyResourceContractTypes(children)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"data": children,
"count": len(children),
})
}
// HandleGetMetrics handles GET /api/resources/{id}/metrics.
func (h *ResourceHandlers) HandleGetMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
path = strings.TrimSuffix(path, "/metrics")
path = strings.TrimSuffix(path, "/")
path = unified.CanonicalResourceID(path)
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
resource, ok := registry.Get(path)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resource.Metrics)
}
// HandleGetResourceTimeline handles GET /api/resources/{id}/timeline.
func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resourceID := strings.TrimPrefix(r.URL.Path, "/api/resources/")
resourceID = strings.TrimSuffix(resourceID, "/timeline")
resourceID = strings.TrimSuffix(resourceID, "/")
resourceID = unified.CanonicalResourceID(resourceID)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
since := time.Time{}
if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
parsed, parseErr := time.Parse(time.RFC3339, raw)
if parseErr != nil {
http.Error(w, "Invalid since value", http.StatusBadRequest)
return
}
since = parsed.UTC()
}
limit := 100
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, parseErr := strconv.Atoi(raw)
if parseErr != nil || parsed <= 0 {
http.Error(w, "Invalid limit value", http.StatusBadRequest)
return
}
limit = parsed
}
filters, err := unified.ParseResourceChangeFilters(r.URL.Query()["kind"], r.URL.Query()["sourceType"], r.URL.Query()["sourceAdapter"])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"resourceId": resourceID,
"recentChanges": changes,
"count": changeCount,
})
}
// HandleStats handles GET /api/resources/stats.
func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
allResources := registry.List()
stats := registry.Stats()
stats.ByType = computeResourceContractByType(allResources)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// HandleLink handles POST /api/resources/{id}/link.
func (h *ResourceHandlers) HandleLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
path = strings.TrimSuffix(path, "/link")
path = strings.TrimSuffix(path, "/")
path = unified.CanonicalResourceID(path)
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
var payload struct {
TargetID string `json:"targetId"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if payload.TargetID == "" {
http.Error(w, "targetId required", http.StatusBadRequest)
return
}
payload.TargetID = unified.CanonicalResourceID(payload.TargetID)
link := unified.ResourceLink{
ResourceA: path,
ResourceB: payload.TargetID,
PrimaryID: path,
Reason: payload.Reason,
CreatedBy: getUserID(r),
CreatedAt: time.Now().UTC(),
}
if err := store.AddLink(link); err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
h.invalidateCache(orgID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"message": "Resources linked",
})
}
// HandleUnlink handles POST /api/resources/{id}/unlink.
func (h *ResourceHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
path = strings.TrimSuffix(path, "/unlink")
path = strings.TrimSuffix(path, "/")
path = unified.CanonicalResourceID(path)
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
var payload struct {
TargetID string `json:"targetId"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if payload.TargetID == "" {
http.Error(w, "targetId required", http.StatusBadRequest)
return
}
payload.TargetID = unified.CanonicalResourceID(payload.TargetID)
exclusion := unified.ResourceExclusion{
ResourceA: path,
ResourceB: payload.TargetID,
Reason: payload.Reason,
CreatedBy: getUserID(r),
CreatedAt: time.Now().UTC(),
}
if err := store.AddExclusion(exclusion); err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
h.invalidateCache(orgID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"message": "Resources unlinked",
})
}
// HandleReportMerge handles POST /api/resources/{id}/report-merge.
func (h *ResourceHandlers) HandleReportMerge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
path = strings.TrimSuffix(path, "/report-merge")
path = strings.TrimSuffix(path, "/")
path = unified.CanonicalResourceID(path)
if path == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
var payload struct {
Sources []string `json:"sources"`
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
registry, err := h.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resource, ok := registry.Get(path)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
if len(resource.Sources) < 2 {
http.Error(w, "Resource is not merged", http.StatusBadRequest)
return
}
sourceTargets := registry.SourceTargets(path)
if len(sourceTargets) == 0 {
http.Error(w, "No source targets found", http.StatusBadRequest)
return
}
filteredSources := make(map[string]struct{})
for _, source := range payload.Sources {
filteredSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
}
reason := strings.TrimSpace(payload.Notes)
if reason == "" {
reason = "reported_incorrect_merge"
}
exclusionsAdded := 0
seen := make(map[string]struct{})
for _, target := range sourceTargets {
if len(filteredSources) > 0 {
if _, ok := filteredSources[strings.ToLower(string(target.Source))]; !ok {
continue
}
}
if target.CandidateID == "" || target.CandidateID == path {
continue
}
key := target.CandidateID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
exclusion := unified.ResourceExclusion{
ResourceA: path,
ResourceB: target.CandidateID,
Reason: reason,
CreatedBy: getUserID(r),
CreatedAt: time.Now().UTC(),
}
if err := store.AddExclusion(exclusion); err != nil {
log.Error().
Err(err).
Str("orgID", orgID).
Str("resourceID", path).
Str("candidateID", target.CandidateID).
Msg("Failed to add resource merge exclusion")
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
exclusionsAdded += 1
}
if exclusionsAdded == 0 {
http.Error(w, "No exclusions created", http.StatusBadRequest)
return
}
log.Info().
Str("orgID", orgID).
Str("resourceID", path).
Int("exclusionsAdded", exclusionsAdded).
Str("userID", getUserID(r)).
Strs("sources", payload.Sources).
Msg("Reported resource merge issue")
h.invalidateCache(orgID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"message": "Merge reported",
"exclusions": exclusionsAdded,
})
}
// buildRegistry constructs a registry for the current tenant.
func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistry, error) {
store, err := h.getStore(orgID)
if err != nil {
return nil, err
}
key := cacheKey(orgID)
seed, err := h.registrySeed(orgID)
if err != nil {
return nil, err
}
h.cacheMu.Lock()
entry, ok := h.registryCache[key]
if ok && entry.registry != nil && !seed.lastUpdate.IsZero() && entry.lastUpdate.Equal(seed.lastUpdate) {
h.cacheMu.Unlock()
return entry.registry, nil
}
h.cacheMu.Unlock()
h.supplementalMu.RLock()
supplementalProviders := make(map[unified.DataSource]SupplementalRecordsProvider, len(h.supplementalRecords))
for source, provider := range h.supplementalRecords {
supplementalProviders[source] = provider
}
h.supplementalMu.RUnlock()
registry := unified.NewRegistry(store)
ownedSources := supplementalSnapshotOwnedSources(supplementalProviders, orgID)
if seed.unifiedSource {
registry.IngestResources(seed.resources)
seedSources := unifiedSeedSources(seed.resources)
for source, provider := range supplementalProviders {
if provider == nil || !sourceOwnedBySupplementalProvider(source, ownedSources) || unifiedSeedIncludesSource(seedSources, source) {
continue
}
records := supplementalRecordsForOrg(provider, orgID)
if len(records) == 0 {
continue
}
registry.IngestRecords(source, records)
}
} else {
registry.IngestSnapshot(unified.SnapshotWithoutSources(seed.snapshot, ownedSources))
for source, provider := range supplementalProviders {
if provider == nil {
continue
}
records := supplementalRecordsForOrg(provider, orgID)
if len(records) == 0 {
continue
}
registry.IngestRecords(source, records)
}
}
h.cacheMu.Lock()
h.registryCache[key] = registryCacheEntry{registry: registry, lastUpdate: seed.lastUpdate}
h.cacheMu.Unlock()
return registry, nil
}
func (h *ResourceHandlers) registrySeed(orgID string) (registrySeed, error) {
seed := registrySeed{}
if orgID != "" && orgID != "default" {
if h.tenantStateProvider == nil {
return seed, errors.New("tenant state provider unavailable")
}
resources, lastUpdate := h.tenantStateProvider.UnifiedResourceSnapshotForTenant(orgID)
seed.resources = resources
seed.lastUpdate = lastUpdate
seed.unifiedSource = true
return seed, nil
}
if provider, ok := any(h.stateProvider).(UnifiedResourceSnapshotProvider); ok {
resources, lastUpdate := provider.UnifiedResourceSnapshot()
if len(resources) > 0 || !lastUpdate.IsZero() {
seed.resources = resources
seed.lastUpdate = lastUpdate
seed.unifiedSource = true
return seed, nil
}
}
if h.stateProvider != nil {
seed.snapshot = h.stateProvider.ReadSnapshot()
seed.lastUpdate = seed.snapshot.LastUpdate
}
return seed, nil
}
func supplementalSnapshotOwnedSources(providers map[unified.DataSource]SupplementalRecordsProvider, orgID string) []unified.DataSource {
if len(providers) == 0 {
return nil
}
owned := make(map[string]unified.DataSource)
for _, provider := range providers {
if provider == nil {
continue
}
var sources []unified.DataSource
if tenantOwner, ok := any(provider).(TenantSupplementalSnapshotSourceOwner); ok {
sources = tenantOwner.SnapshotOwnedSourcesForOrg(orgID)
} else if owner, ok := any(provider).(SupplementalSnapshotSourceOwner); ok {
sources = owner.SnapshotOwnedSources()
}
for _, source := range sources {
key := strings.ToLower(strings.TrimSpace(string(source)))
if key == "" {
continue
}
owned[key] = unified.DataSource(key)
}
}
if len(owned) == 0 {
return nil
}
keys := make([]string, 0, len(owned))
for key := range owned {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]unified.DataSource, 0, len(keys))
for _, key := range keys {
out = append(out, owned[key])
}
return out
}
func supplementalRecordsForOrg(provider SupplementalRecordsProvider, orgID string) []unified.IngestRecord {
if provider == nil {
return nil
}
var records []unified.IngestRecord
if tenantProvider, ok := any(provider).(TenantSupplementalRecordsProvider); ok {
records = tenantProvider.GetCurrentRecordsForOrg(orgID)
} else {
records = provider.GetCurrentRecords()
}
if len(records) == 0 {
return nil
}
out := make([]unified.IngestRecord, len(records))
copy(out, records)
return out
}
func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]struct{} {
if len(resources) == 0 {
return nil
}
sources := make(map[unified.DataSource]struct{})
for _, resource := range resources {
for _, source := range resource.Sources {
if normalized := normalizeDataSourceAlias(source); normalized != "" {
sources[normalized] = struct{}{}
}
}
for source := range resource.SourceStatus {
if normalized := normalizeDataSourceAlias(source); normalized != "" {
sources[normalized] = struct{}{}
}
}
switch {
case resource.TrueNAS != nil:
sources[unified.SourceTrueNAS] = struct{}{}
case resource.VMware != nil:
sources[unified.SourceVMware] = struct{}{}
}
}
return sources
}
func unifiedSeedIncludesSource(seedSources map[unified.DataSource]struct{}, source unified.DataSource) bool {
if len(seedSources) == 0 {
return false
}
_, ok := seedSources[normalizeDataSourceAlias(source)]
return ok
}
func sourceOwnedBySupplementalProvider(source unified.DataSource, ownedSources []unified.DataSource) bool {
normalized := normalizeDataSourceAlias(source)
if normalized == "" {
return false
}
for _, ownedSource := range ownedSources {
if normalizeDataSourceAlias(ownedSource) == normalized {
return true
}
}
return false
}
func normalizeDataSourceAlias(source unified.DataSource) unified.DataSource {
switch strings.ToLower(strings.TrimSpace(string(source))) {
case "", "all":
return ""
case "k8s":
return unified.SourceK8s
case "vmware-vsphere":
return unified.SourceVMware
default:
return unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
}
}
func (h *ResourceHandlers) getStore(orgID string) (unified.ResourceStore, error) {
h.storeMu.Lock()
defer h.storeMu.Unlock()
key := cacheKey(orgID)
if store, ok := h.stores[key]; ok {
return store, nil
}
dataDir := ""
if h.cfg != nil {
dataDir = h.cfg.DataPath
}
store, err := unified.NewSQLiteResourceStore(dataDir, key)
if err != nil {
return nil, err
}
h.stores[key] = store
return store, nil
}
func (h *ResourceHandlers) invalidateCache(orgID string) {
key := cacheKey(orgID)
h.cacheMu.Lock()
delete(h.registryCache, key)
h.cacheMu.Unlock()
}
func cacheKey(orgID string) string {
key := strings.TrimSpace(orgID)
if key == "" {
key = "default"
}
return key
}
// ResourcesResponse represents the list response for the unified resources API.
type ResourcesResponse struct {
Data []unified.Resource `json:"data"`
Meta ResourcesMeta `json:"meta"`
Aggregations unified.ResourceStats `json:"aggregations"`
}
func EmptyResourcesResponse() ResourcesResponse {
return ResourcesResponse{}.NormalizeCollections()
}
func (r ResourcesResponse) NormalizeCollections() ResourcesResponse {
if r.Data == nil {
r.Data = []unified.Resource{}
}
if r.Aggregations.ByType == nil {
r.Aggregations.ByType = map[unified.ResourceType]int{}
}
if r.Aggregations.ByStatus == nil {
r.Aggregations.ByStatus = map[unified.ResourceStatus]int{}
}
if r.Aggregations.BySource == nil {
r.Aggregations.BySource = map[unified.DataSource]int{}
}
return r
}
// ResourcesMeta represents pagination metadata.
type ResourcesMeta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
}
type StorageSummaryResponse struct {
GeneratedAt time.Time `json:"generatedAt"`
TotalResources int `json:"totalResources"`
RiskyResources int `json:"riskyResources"`
CriticalResources int `json:"criticalResources"`
WarningResources int `json:"warningResources"`
ProtectionReducedCount int `json:"protectionReducedCount"`
RebuildInProgressCount int `json:"rebuildInProgressCount"`
DependentResourceCount int `json:"dependentResourceCount"`
ProtectedWorkloadCount int `json:"protectedWorkloadCount"`
AffectedDatastoreCount int `json:"affectedDatastoreCount"`
ByPlatform map[string]int `json:"byPlatform"`
ByResourceType map[string]int `json:"byResourceType"`
ByIncidentCategory map[string]int `json:"byIncidentCategory"`
TopIncidents []StorageSummaryIncident `json:"topIncidents"`
}
type DashboardOverviewSummaryResponse struct {
Health DashboardOverviewHealthSummary `json:"health"`
Infrastructure DashboardOverviewInfrastructureSummary `json:"infrastructure"`
Workloads DashboardOverviewWorkloadsSummary `json:"workloads"`
Storage DashboardOverviewStorageSummary `json:"storage"`
ProblemResources []DashboardOverviewProblemResourceRow `json:"problemResources"`
}
type DashboardOverviewHealthSummary struct {
TotalResources int `json:"totalResources"`
ByStatus map[string]int `json:"byStatus"`
}
type DashboardOverviewInfrastructureSummary struct {
Total int `json:"total"`
ByStatus map[string]int `json:"byStatus"`
ByType map[string]int `json:"byType"`
TopCPU []DashboardOverviewTopResource `json:"topCPU"`
TopMemory []DashboardOverviewTopResource `json:"topMemory"`
}
type DashboardOverviewTopResource struct {
ID string `json:"id"`
Name string `json:"name"`
Percent float64 `json:"percent"`
MetricsTarget *unified.MetricsTarget `json:"metricsTarget,omitempty"`
}
type DashboardOverviewWorkloadsSummary struct {
Total int `json:"total"`
Running int `json:"running"`
Stopped int `json:"stopped"`
ByType map[string]int `json:"byType"`
}
type DashboardOverviewStorageSummary struct {
Total int `json:"total"`
TotalCapacity int64 `json:"totalCapacity"`
TotalUsed int64 `json:"totalUsed"`
WarningCount int `json:"warningCount"`
CriticalCount int `json:"criticalCount"`
}
type DashboardOverviewProblemResourceRow struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Status string `json:"status"`
LastSeen string `json:"lastSeen,omitempty"`
Sources []unified.DataSource `json:"sources,omitempty"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
Policy *unified.ResourcePolicy `json:"policy,omitempty"`
CanonicalIdentity *unified.CanonicalIdentity `json:"canonicalIdentity,omitempty"`
Problems []string `json:"problems"`
WorstValue float64 `json:"worstValue"`
}
func EmptyDashboardOverviewSummaryResponse() DashboardOverviewSummaryResponse {
return DashboardOverviewSummaryResponse{}.NormalizeCollections()
}
func (r DashboardOverviewSummaryResponse) NormalizeCollections() DashboardOverviewSummaryResponse {
if r.Health.ByStatus == nil {
r.Health.ByStatus = map[string]int{}
}
if r.Infrastructure.ByStatus == nil {
r.Infrastructure.ByStatus = map[string]int{}
}
if r.Infrastructure.ByType == nil {
r.Infrastructure.ByType = map[string]int{}
}
if r.Infrastructure.TopCPU == nil {
r.Infrastructure.TopCPU = []DashboardOverviewTopResource{}
}
if r.Infrastructure.TopMemory == nil {
r.Infrastructure.TopMemory = []DashboardOverviewTopResource{}
}
if r.Workloads.ByType == nil {
r.Workloads.ByType = map[string]int{}
}
if r.ProblemResources == nil {
r.ProblemResources = []DashboardOverviewProblemResourceRow{}
}
return r
}
func EmptyStorageSummaryResponse() StorageSummaryResponse {
return StorageSummaryResponse{}.NormalizeCollections()
}
func (r StorageSummaryResponse) NormalizeCollections() StorageSummaryResponse {
if r.ByPlatform == nil {
r.ByPlatform = map[string]int{}
}
if r.ByResourceType == nil {
r.ByResourceType = map[string]int{}
}
if r.ByIncidentCategory == nil {
r.ByIncidentCategory = map[string]int{}
}
if r.TopIncidents == nil {
r.TopIncidents = []StorageSummaryIncident{}
}
return r
}
type StorageSummaryIncident struct {
ResourceID string `json:"resourceId"`
ResourceType string `json:"resourceType"`
Name string `json:"name"`
ParentName string `json:"parentName,omitempty"`
Platform string `json:"platform,omitempty"`
Topology string `json:"topology,omitempty"`
Status string `json:"status"`
IncidentCount int `json:"incidentCount"`
IncidentCategory string `json:"incidentCategory,omitempty"`
IncidentLabel string `json:"incidentLabel,omitempty"`
IncidentSeverity string `json:"incidentSeverity,omitempty"`
IncidentPriority int `json:"incidentPriority,omitempty"`
IncidentSummary string `json:"incidentSummary,omitempty"`
IncidentImpactSummary string `json:"incidentImpactSummary,omitempty"`
IncidentUrgency string `json:"incidentUrgency,omitempty"`
IncidentAction string `json:"incidentAction,omitempty"`
ProtectionReduced bool `json:"protectionReduced,omitempty"`
RebuildInProgress bool `json:"rebuildInProgress,omitempty"`
ConsumerCount int `json:"consumerCount,omitempty"`
ProtectedWorkloads int `json:"protectedWorkloads,omitempty"`
AffectedDatastores int `json:"affectedDatastores,omitempty"`
}
type StorageIncidentsResponse struct {
GeneratedAt time.Time `json:"generatedAt"`
TotalResources int `json:"totalResources"`
CriticalResources int `json:"criticalResources"`
WarningResources int `json:"warningResources"`
ByCategory map[string]int `json:"byCategory"`
ByUrgency map[string]int `json:"byUrgency"`
Sections []StorageIncidentSection `json:"sections"`
}
func EmptyStorageIncidentsResponse() StorageIncidentsResponse {
return StorageIncidentsResponse{}.NormalizeCollections()
}
func (r StorageIncidentsResponse) NormalizeCollections() StorageIncidentsResponse {
if r.ByCategory == nil {
r.ByCategory = map[string]int{}
}
if r.ByUrgency == nil {
r.ByUrgency = map[string]int{}
}
if r.Sections == nil {
r.Sections = []StorageIncidentSection{}
}
for i := range r.Sections {
if r.Sections[i].Resources == nil {
r.Sections[i].Resources = []StorageSummaryIncident{}
}
}
return r
}
type StorageIncidentSection struct {
Category string `json:"category"`
Label string `json:"label"`
ResourceCount int `json:"resourceCount"`
CriticalResources int `json:"criticalResources"`
WarningResources int `json:"warningResources"`
PrimaryUrgency string `json:"primaryUrgency,omitempty"`
Resources []StorageSummaryIncident `json:"resources"`
}
type registryCacheEntry struct {
registry *unified.ResourceRegistry
lastUpdate time.Time
}
func buildDashboardOverviewSummaryResponse(resources []unified.Resource) DashboardOverviewSummaryResponse {
response := EmptyDashboardOverviewSummaryResponse()
if len(resources) == 0 {
return response
}
infrastructureResources := make([]unified.Resource, 0, len(resources))
problemResources := make([]DashboardOverviewProblemResourceRow, 0, len(resources))
for _, resource := range resources {
resourceType := strings.TrimSpace(string(resource.Type))
status := strings.TrimSpace(string(resource.Status))
if status == "" {
status = "unknown"
}
response.Health.TotalResources++
response.Health.ByStatus[status]++
if isDashboardInfrastructureResourceType(resource.Type) {
infrastructureResources = append(infrastructureResources, resource)
response.Infrastructure.Total++
response.Infrastructure.ByStatus[status]++
response.Infrastructure.ByType[resourceType]++
}
if isDashboardWorkloadResourceType(resource.Type) {
response.Workloads.Total++
response.Workloads.ByType[resourceType]++
switch status {
case "online", "running":
response.Workloads.Running++
case "offline", "stopped":
response.Workloads.Stopped++
}
}
if isDashboardStorageResourceType(resource.Type) {
response.Storage.Total++
if total := dashboardMetricTotal(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }); total > 0 {
response.Storage.TotalCapacity += total
}
if used := dashboardMetricUsed(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }); used > 0 {
response.Storage.TotalUsed += used
}
diskPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }))
switch {
case diskPercent > 90:
response.Storage.CriticalCount++
case diskPercent > 80:
response.Storage.WarningCount++
}
}
if row, ok := buildDashboardProblemResourceRow(resource); ok {
problemResources = append(problemResources, row)
}
}
response.Infrastructure.TopCPU = buildDashboardTopInfrastructureResources(
infrastructureResources,
func(resource unified.Resource) float64 {
return dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.CPU }))
},
)
response.Infrastructure.TopMemory = buildDashboardTopInfrastructureResources(
infrastructureResources,
func(resource unified.Resource) float64 {
return dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Memory }))
},
)
sort.Slice(problemResources, func(i, j int) bool {
if problemResources[i].WorstValue == problemResources[j].WorstValue {
return strings.ToLower(problemResources[i].Name) < strings.ToLower(problemResources[j].Name)
}
return problemResources[i].WorstValue > problemResources[j].WorstValue
})
if len(problemResources) > 8 {
problemResources = problemResources[:8]
}
response.ProblemResources = problemResources
return response.NormalizeCollections()
}
func buildDashboardTopInfrastructureResources(
resources []unified.Resource,
metric func(unified.Resource) float64,
) []DashboardOverviewTopResource {
rows := make([]DashboardOverviewTopResource, 0, len(resources))
for _, resource := range resources {
percent := metric(resource)
if percent <= 0 {
continue
}
rows = append(rows, DashboardOverviewTopResource{
ID: resource.ID,
Name: dashboardResourceLabel(resource),
Percent: percent,
MetricsTarget: cloneDashboardMetricsTarget(resource.MetricsTarget),
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Percent == rows[j].Percent {
return strings.ToLower(rows[i].Name) < strings.ToLower(rows[j].Name)
}
return rows[i].Percent > rows[j].Percent
})
if len(rows) > 5 {
rows = rows[:5]
}
return rows
}
func cloneDashboardMetricsTarget(target *unified.MetricsTarget) *unified.MetricsTarget {
if target == nil {
return nil
}
cloned := *target
return &cloned
}
func buildDashboardProblemResourceRow(resource unified.Resource) (DashboardOverviewProblemResourceRow, bool) {
problems := make([]string, 0, 4)
worstValue := 0.0
status := strings.TrimSpace(strings.ToLower(string(resource.Status)))
switch status {
case "offline", "error", "failed", "down", "unreachable", "disconnected", "timeout", "stopped", "inactive":
problems = append(problems, "Offline")
worstValue = 200
case "degraded", "warning", "maintenance", "syncing", "initializing", "starting", "pending", "partial", "unknown", "recovering", "pausing", "restarting":
problems = append(problems, "Degraded")
worstValue = maxDashboardProblemValue(worstValue, 150)
}
cpuPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.CPU }))
if cpuPercent >= 90 {
problems = append(problems, "CPU "+strconv.Itoa(int(cpuPercent+0.5))+"%")
worstValue = maxDashboardProblemValue(worstValue, cpuPercent)
}
memoryPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Memory }))
if memoryPercent >= 85 {
problems = append(problems, "Memory "+strconv.Itoa(int(memoryPercent+0.5))+"%")
worstValue = maxDashboardProblemValue(worstValue, memoryPercent)
}
diskPercent := dashboardMetricPercent(nilSafeMetric(resource.Metrics, func(m *unified.ResourceMetrics) *unified.MetricValue { return m.Disk }))
if diskPercent >= 90 {
problems = append(problems, "Disk "+strconv.Itoa(int(diskPercent+0.5))+"%")
worstValue = maxDashboardProblemValue(worstValue, diskPercent)
}
if len(problems) == 0 {
return DashboardOverviewProblemResourceRow{}, false
}
row := DashboardOverviewProblemResourceRow{
ID: resource.ID,
Type: string(resource.Type),
Name: dashboardResourceLabel(resource),
Status: status,
Sources: append([]unified.DataSource(nil), resource.Sources...),
AISafeSummary: strings.TrimSpace(resource.AISafeSummary),
CanonicalIdentity: cloneDashboardCanonicalIdentity(resource.Canonical),
Policy: unified.CloneResourcePolicy(resource.Policy),
Problems: problems,
WorstValue: worstValue,
}
if !resource.LastSeen.IsZero() {
row.LastSeen = resource.LastSeen.UTC().Format(time.RFC3339Nano)
}
return row, true
}
func dashboardResourceLabel(resource unified.Resource) string {
return unified.ResourcePolicyLabel(
unified.ResourceDisplayName(resource),
resource.AISafeSummary,
resource.Policy,
)
}
func cloneDashboardCanonicalIdentity(identity *unified.CanonicalIdentity) *unified.CanonicalIdentity {
if identity == nil {
return nil
}
cloned := *identity
if len(identity.Aliases) > 0 {
cloned.Aliases = append([]string(nil), identity.Aliases...)
}
return &cloned
}
func isDashboardInfrastructureResourceType(resourceType unified.ResourceType) bool {
switch strings.TrimSpace(string(resourceType)) {
case "agent", "docker-host", "k8s-cluster", "k8s-node":
return true
default:
return false
}
}
func isDashboardWorkloadResourceType(resourceType unified.ResourceType) bool {
switch strings.TrimSpace(string(resourceType)) {
case "vm", "system-container", "app-container", "oci-container", "pod", "jail":
return true
default:
return false
}
}
func isDashboardStorageResourceType(resourceType unified.ResourceType) bool {
switch strings.TrimSpace(string(resourceType)) {
case "storage", "datastore", "pool", "dataset", "physical_disk", "ceph":
return true
default:
return false
}
}
func nilSafeMetric(
metrics *unified.ResourceMetrics,
pick func(*unified.ResourceMetrics) *unified.MetricValue,
) *unified.MetricValue {
if metrics == nil {
return nil
}
return pick(metrics)
}
func dashboardMetricPercent(metric *unified.MetricValue) float64 {
if metric == nil {
return 0
}
if metric.Percent > 0 {
return metric.Percent
}
if metric.Value > 0 {
return metric.Value
}
if metric.Total != nil && metric.Used != nil && *metric.Total > 0 {
return (float64(*metric.Used) / float64(*metric.Total)) * 100
}
return 0
}
func dashboardMetricTotal(
metrics *unified.ResourceMetrics,
pick func(*unified.ResourceMetrics) *unified.MetricValue,
) int64 {
metric := nilSafeMetric(metrics, pick)
if metric == nil || metric.Total == nil {
return 0
}
return *metric.Total
}
func dashboardMetricUsed(
metrics *unified.ResourceMetrics,
pick func(*unified.ResourceMetrics) *unified.MetricValue,
) int64 {
metric := nilSafeMetric(metrics, pick)
if metric == nil || metric.Used == nil {
return 0
}
return *metric.Used
}
func maxDashboardProblemValue(left, right float64) float64 {
if right > left {
return right
}
return left
}
func buildStorageSummaryResponse(resources []unified.Resource) StorageSummaryResponse {
response := EmptyStorageSummaryResponse()
response.GeneratedAt = time.Now().UTC()
incidentCandidates := make([]unified.Resource, 0, len(resources))
for _, resource := range resources {
response.TotalResources++
platform := storageSummaryPlatform(resource)
if platform == "" {
platform = "unknown"
}
response.ByPlatform[platform]++
response.ByResourceType[string(resourceContractType(resource))]++
if storageSummaryProtectionReduced(resource) {
response.ProtectionReducedCount++
}
if storageSummaryRebuildInProgress(resource) {
response.RebuildInProgressCount++
}
response.DependentResourceCount += storageSummaryConsumerCount(resource)
response.ProtectedWorkloadCount += storageSummaryProtectedWorkloadCount(resource)
response.AffectedDatastoreCount += storageSummaryAffectedDatastoreCount(resource)
if resource.IncidentCount > 0 {
response.RiskyResources++
switch resource.IncidentSeverity {
case storagehealth.RiskCritical:
response.CriticalResources++
default:
response.WarningResources++
}
category := strings.TrimSpace(resource.IncidentCategory)
if category != "" {
response.ByIncidentCategory[category]++
}
incidentCandidates = append(incidentCandidates, resource)
}
}
sort.Slice(incidentCandidates, func(i, j int) bool {
return storageIncidentLess(incidentCandidates[i], incidentCandidates[j])
})
for i, resource := range incidentCandidates {
if i == 10 {
break
}
response.TopIncidents = append(response.TopIncidents, buildStorageSummaryIncident(resource))
}
return response.NormalizeCollections()
}
func buildStorageIncidentsResponse(resources []unified.Resource) StorageIncidentsResponse {
response := EmptyStorageIncidentsResponse()
response.GeneratedAt = time.Now().UTC()
if len(resources) == 0 {
return response.NormalizeCollections()
}
incidentResources := cloneStorageIncidentSubjects(resources)
sort.Slice(incidentResources, func(i, j int) bool {
return storageIncidentLess(incidentResources[i], incidentResources[j])
})
sectionIndex := make(map[string]int)
for _, resource := range incidentResources {
response.TotalResources++
switch resource.IncidentSeverity {
case storagehealth.RiskCritical:
response.CriticalResources++
default:
response.WarningResources++
}
category := strings.TrimSpace(resource.IncidentCategory)
if category == "" {
category = unified.IncidentCategoryHealth
}
response.ByCategory[category]++
urgency := strings.TrimSpace(resource.IncidentUrgency)
if urgency == "" {
urgency = unified.IncidentUrgencyPlan
}
response.ByUrgency[urgency]++
idx, ok := sectionIndex[category]
if !ok {
response.Sections = append(response.Sections, StorageIncidentSection{
Category: category,
Label: storageIncidentSectionLabel(category),
Resources: make([]StorageSummaryIncident, 0, 8),
})
idx = len(response.Sections) - 1
sectionIndex[category] = idx
}
section := &response.Sections[idx]
section.ResourceCount++
switch resource.IncidentSeverity {
case storagehealth.RiskCritical:
section.CriticalResources++
default:
section.WarningResources++
}
if storageIncidentUrgencyRank(urgency) > storageIncidentUrgencyRank(section.PrimaryUrgency) {
section.PrimaryUrgency = urgency
}
section.Resources = append(section.Resources, buildStorageSummaryIncident(resource))
}
sort.SliceStable(response.Sections, func(i, j int) bool {
left := response.Sections[i]
right := response.Sections[j]
if storageIncidentCategoryRank(left.Category) != storageIncidentCategoryRank(right.Category) {
return storageIncidentCategoryRank(left.Category) < storageIncidentCategoryRank(right.Category)
}
if left.CriticalResources != right.CriticalResources {
return left.CriticalResources > right.CriticalResources
}
if left.ResourceCount != right.ResourceCount {
return left.ResourceCount > right.ResourceCount
}
return left.Label < right.Label
})
return response.NormalizeCollections()
}
func buildStorageSummaryIncident(resource unified.Resource) StorageSummaryIncident {
return StorageSummaryIncident{
ResourceID: resource.ID,
ResourceType: string(resourceContractType(resource)),
Name: resource.Name,
ParentName: resource.ParentName,
Platform: storageSummaryPlatform(resource),
Topology: storageSummaryTopology(resource),
Status: string(resource.Status),
IncidentCount: resource.IncidentCount,
IncidentCategory: resource.IncidentCategory,
IncidentLabel: resource.IncidentLabel,
IncidentSeverity: string(resource.IncidentSeverity),
IncidentPriority: resource.IncidentPriority,
IncidentSummary: resource.IncidentSummary,
IncidentImpactSummary: resource.IncidentImpactSummary,
IncidentUrgency: resource.IncidentUrgency,
IncidentAction: resource.IncidentAction,
ProtectionReduced: storageSummaryProtectionReduced(resource),
RebuildInProgress: storageSummaryRebuildInProgress(resource),
ConsumerCount: storageSummaryConsumerCount(resource),
ProtectedWorkloads: storageSummaryProtectedWorkloadCount(resource),
AffectedDatastores: storageSummaryAffectedDatastoreCount(resource),
}
}
func cloneStorageIncidentSubjects(resources []unified.Resource) []unified.Resource {
cloned := make([]unified.Resource, len(resources))
copy(cloned, resources)
return cloned
}
func storageIncidentLess(left, right unified.Resource) bool {
if left.IncidentPriority != right.IncidentPriority {
return left.IncidentPriority > right.IncidentPriority
}
if left.IncidentSeverity != right.IncidentSeverity {
return left.IncidentSeverity > right.IncidentSeverity
}
if left.Name != right.Name {
return left.Name < right.Name
}
return left.ID < right.ID
}
func storageIncidentSectionLabel(category string) string {
switch strings.TrimSpace(category) {
case unified.IncidentCategoryRecoverability:
return "Backup & Recoverability"
case unified.IncidentCategoryProtection:
return "Protection & Redundancy"
case unified.IncidentCategoryRebuild:
return "Rebuild & Recovery"
case unified.IncidentCategoryCapacity:
return "Capacity Pressure"
case unified.IncidentCategoryDiskHealth:
return "Disk Health"
case unified.IncidentCategoryAvailability:
return "Availability"
default:
return "Storage Health"
}
}
func storageIncidentCategoryRank(category string) int {
switch strings.TrimSpace(category) {
case unified.IncidentCategoryRecoverability:
return 1
case unified.IncidentCategoryProtection:
return 2
case unified.IncidentCategoryRebuild:
return 3
case unified.IncidentCategoryCapacity:
return 4
case unified.IncidentCategoryDiskHealth:
return 5
case unified.IncidentCategoryAvailability:
return 6
default:
return 7
}
}
func storageIncidentUrgencyRank(urgency string) int {
switch strings.TrimSpace(urgency) {
case unified.IncidentUrgencyNow:
return 4
case unified.IncidentUrgencyToday:
return 3
case unified.IncidentUrgencyPlan:
return 2
case unified.IncidentUrgencyMonitor:
return 1
default:
return 0
}
}
func isStorageSummaryResource(resource unified.Resource) bool {
switch unified.CanonicalResourceType(resource.Type) {
case unified.ResourceTypeStorage, unified.ResourceTypePhysicalDisk, unified.ResourceTypePBS:
return true
case unified.ResourceTypeAgent:
if resource.TrueNAS != nil {
return true
}
if resource.Agent != nil && (resource.Agent.Unraid != nil || resource.Agent.StorageRisk != nil) {
return true
}
}
return false
}
func storageSummaryPlatform(resource unified.Resource) string {
if resource.Storage != nil && strings.TrimSpace(resource.Storage.Platform) != "" {
return strings.TrimSpace(resource.Storage.Platform)
}
if resource.TrueNAS != nil {
return "truenas"
}
if resource.PBS != nil {
return "pbs"
}
if resource.Agent != nil && resource.Agent.Unraid != nil {
return "unraid"
}
for _, source := range resource.Sources {
if trimmed := strings.TrimSpace(string(source)); trimmed != "" {
return trimmed
}
}
return ""
}
func storageSummaryTopology(resource unified.Resource) string {
if resource.Storage != nil && strings.TrimSpace(resource.Storage.Topology) != "" {
return strings.TrimSpace(resource.Storage.Topology)
}
switch unified.CanonicalResourceType(resource.Type) {
case unified.ResourceTypePhysicalDisk:
return "disk"
case unified.ResourceTypePBS:
return "backup-server"
case unified.ResourceTypeAgent:
if resource.Agent != nil && resource.Agent.Unraid != nil {
return "array-host"
}
if resource.TrueNAS != nil {
return "nas"
}
}
return string(resourceContractType(resource))
}
func storageSummaryProtectionReduced(resource unified.Resource) bool {
if resource.Storage != nil && resource.Storage.ProtectionReduced {
return true
}
if resource.Agent != nil && resource.Agent.ProtectionReduced {
return true
}
if resource.TrueNAS != nil && resource.TrueNAS.ProtectionReduced {
return true
}
return false
}
func storageSummaryRebuildInProgress(resource unified.Resource) bool {
if resource.Storage != nil && resource.Storage.RebuildInProgress {
return true
}
if resource.Agent != nil && resource.Agent.RebuildInProgress {
return true
}
if resource.TrueNAS != nil && resource.TrueNAS.RebuildInProgress {
return true
}
return false
}
func storageSummaryConsumerCount(resource unified.Resource) int {
if resource.Storage != nil {
return resource.Storage.ConsumerCount
}
return 0
}
func storageSummaryProtectedWorkloadCount(resource unified.Resource) int {
if resource.PBS != nil {
return resource.PBS.ProtectedWorkloadCount
}
return 0
}
func storageSummaryAffectedDatastoreCount(resource unified.Resource) int {
if resource.PBS != nil {
return resource.PBS.AffectedDatastoreCount
}
return 0
}
// Filtering helpers.
type listFilters struct {
types map[unified.ResourceType]struct{}
sources map[unified.DataSource]struct{}
statuses map[unified.ResourceStatus]struct{}
parent string
cluster string
namespace string
query string
tags map[string]struct{}
page int
limit int
sortField string
sortOrder string
}
func parseListFilters(r *http.Request) listFilters {
filters := listFilters{
types: parseResourceTypes(r.URL.Query().Get("type")),
sources: parseSources(r.URL.Query().Get("source")),
statuses: parseStatuses(r.URL.Query().Get("status")),
parent: strings.TrimSpace(r.URL.Query().Get("parent")),
cluster: strings.TrimSpace(r.URL.Query().Get("cluster")),
namespace: strings.TrimSpace(r.URL.Query().Get("namespace")),
query: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))),
tags: parseTags(r.URL.Query().Get("tags")),
page: parseIntDefault(r.URL.Query().Get("page"), 1),
limit: parseIntDefault(r.URL.Query().Get("limit"), 50),
sortField: strings.TrimSpace(r.URL.Query().Get("sort")),
sortOrder: strings.TrimSpace(strings.ToLower(r.URL.Query().Get("order"))),
}
if filters.page < 1 {
filters.page = 1
}
if filters.limit < 1 {
filters.limit = 50
}
if filters.limit > 100 {
filters.limit = 100
}
if filters.sortField == "" {
filters.sortField = "name"
}
if filters.sortOrder != "desc" {
filters.sortOrder = "asc"
}
return filters
}
func applyFilters(resources []unified.Resource, filters listFilters) []unified.Resource {
out := make([]unified.Resource, 0, len(resources))
for _, r := range resources {
if len(filters.types) > 0 {
if _, ok := filters.types[resourceContractType(r)]; !ok {
continue
}
}
if len(filters.sources) > 0 {
matched := false
for _, source := range r.Sources {
if _, ok := filters.sources[source]; ok {
matched = true
break
}
}
if !matched {
continue
}
}
if len(filters.statuses) > 0 {
if _, ok := filters.statuses[r.Status]; !ok {
continue
}
}
if filters.parent != "" {
if r.ParentID == nil || *r.ParentID != filters.parent {
continue
}
}
if filters.cluster != "" {
cluster := strings.ToLower(filters.cluster)
identityCluster := strings.ToLower(r.Identity.ClusterName)
proxmoxCluster := ""
if r.Proxmox != nil {
proxmoxCluster = strings.ToLower(r.Proxmox.ClusterName)
}
dockerSwarmClusterName := ""
dockerSwarmClusterID := ""
if r.Docker != nil && r.Docker.Swarm != nil {
dockerSwarmClusterName = strings.ToLower(strings.TrimSpace(r.Docker.Swarm.ClusterName))
dockerSwarmClusterID = strings.ToLower(strings.TrimSpace(r.Docker.Swarm.ClusterID))
}
if identityCluster != cluster && proxmoxCluster != cluster && dockerSwarmClusterName != cluster && dockerSwarmClusterID != cluster {
continue
}
}
if filters.namespace != "" {
if r.Kubernetes == nil {
continue
}
want := strings.ToLower(filters.namespace)
got := strings.ToLower(strings.TrimSpace(r.Kubernetes.Namespace))
if got != want {
continue
}
}
if filters.query != "" {
name := strings.ToLower(r.Name)
if !strings.Contains(name, filters.query) {
continue
}
}
if len(filters.tags) > 0 {
matched := false
for _, tag := range r.Tags {
if _, ok := filters.tags[strings.ToLower(tag)]; ok {
matched = true
break
}
}
if !matched {
continue
}
}
out = append(out, r)
}
return out
}
func applySorting(resources []unified.Resource, field, order string) {
sort.SliceStable(resources, func(i, j int) bool {
a := resources[i]
b := resources[j]
comparison := 0
switch field {
case "status":
comparison = strings.Compare(string(a.Status), string(b.Status))
case "type":
comparison = strings.Compare(string(a.Type), string(b.Type))
case "lastSeen":
if a.LastSeen.Before(b.LastSeen) {
comparison = -1
} else if b.LastSeen.Before(a.LastSeen) {
comparison = 1
}
default:
comparison = unified.CompareResourcesByCanonicalName(a, b)
}
if comparison == 0 {
comparison = unified.CompareResourcesByCanonicalName(a, b)
}
if order == "desc" {
return comparison > 0
}
return comparison < 0
})
}
func paginate(resources []unified.Resource, page, limit int) ([]unified.Resource, ResourcesMeta) {
total := len(resources)
start := (page - 1) * limit
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
paged := resources[start:end]
totalPages := total / limit
if total%limit != 0 {
totalPages++
}
meta := ResourcesMeta{
Page: page,
Limit: limit,
Total: total,
TotalPages: totalPages,
}
return paged, meta
}
func parseResourceTypes(raw string) map[unified.ResourceType]struct{} {
result := make(map[unified.ResourceType]struct{})
for _, part := range splitCSV(raw) {
for _, resourceType := range resourceTypeFilterAdapter(part) {
result[resourceType] = struct{}{}
}
}
return result
}
func unsupportedResourceTypeFilterTokens(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var unsupported []string
for _, part := range splitCSV(raw) {
if !isSupportedResourceTypeFilterToken(part) {
unsupported = append(unsupported, part)
}
}
return unsupported
}
func isSupportedResourceTypeFilterToken(token string) bool {
return len(resourceTypeFilterAdapter(token)) > 0
}
func resourceTypeFilterAdapter(token string) []unified.ResourceType {
switch token {
case "agent", "agents", "node", "nodes":
return []unified.ResourceType{unified.ResourceTypeAgent}
case "docker-host":
return []unified.ResourceType{"docker-host"}
case "vm", "vms":
return []unified.ResourceType{unified.ResourceTypeVM}
case "system-container", "system-containers", "oci-container":
return []unified.ResourceType{unified.ResourceTypeSystemContainer}
case "app-container", "app-containers":
return []unified.ResourceType{unified.ResourceTypeAppContainer}
case "docker-service", "service", "services":
return []unified.ResourceType{unified.ResourceTypeDockerService}
case "pod", "pods":
return []unified.ResourceType{unified.ResourceTypePod}
case "k8s-cluster", "k8s-clusters":
return []unified.ResourceType{unified.ResourceTypeK8sCluster}
case "k8s-node", "k8s-nodes":
return []unified.ResourceType{unified.ResourceTypeK8sNode}
case "k8s-deployment", "k8s-deployments":
return []unified.ResourceType{unified.ResourceTypeK8sDeployment}
case "storage":
return []unified.ResourceType{unified.ResourceTypeStorage}
case "pbs":
return []unified.ResourceType{unified.ResourceTypePBS}
case "pmg":
return []unified.ResourceType{unified.ResourceTypePMG}
case "ceph", "pool":
return []unified.ResourceType{unified.ResourceTypeCeph}
case "physical_disk", "physical-disk", "physicaldisk", "disk":
return []unified.ResourceType{unified.ResourceTypePhysicalDisk}
default:
return nil
}
}
func parseSources(raw string) map[unified.DataSource]struct{} {
result := make(map[unified.DataSource]struct{})
for _, part := range splitCSV(raw) {
switch part {
case "proxmox":
result[unified.SourceProxmox] = struct{}{}
case "agent":
result[unified.SourceAgent] = struct{}{}
case "docker":
result[unified.SourceDocker] = struct{}{}
case "pbs":
result[unified.SourcePBS] = struct{}{}
case "pmg":
result[unified.SourcePMG] = struct{}{}
case "kubernetes":
result[unified.SourceK8s] = struct{}{}
case "truenas":
result[unified.SourceTrueNAS] = struct{}{}
case "vmware", "vmware-vsphere":
result[unified.SourceVMware] = struct{}{}
}
}
return result
}
func parseStatuses(raw string) map[unified.ResourceStatus]struct{} {
result := make(map[unified.ResourceStatus]struct{})
for _, part := range splitCSV(raw) {
switch part {
case "online":
result[unified.StatusOnline] = struct{}{}
case "offline":
result[unified.StatusOffline] = struct{}{}
case "warning":
result[unified.StatusWarning] = struct{}{}
case "unknown":
result[unified.StatusUnknown] = struct{}{}
}
}
return result
}
func parseTags(raw string) map[string]struct{} {
result := make(map[string]struct{})
for _, part := range splitCSV(raw) {
if part == "" {
continue
}
result[strings.ToLower(part)] = struct{}{}
}
return result
}
func attachDiscoveryTargets(resources []unified.Resource) {
for i := range resources {
attachDiscoveryTarget(&resources[i])
}
}
func attachDiscoveryTarget(resource *unified.Resource) {
if resource == nil {
return
}
resource.DiscoveryTarget = buildDiscoveryTarget(*resource)
}
func attachMetricsTargets(resources []unified.Resource, registry *unified.ResourceRegistry) {
for i := range resources {
attachMetricsTarget(&resources[i], registry)
}
}
func attachMetricsTarget(resource *unified.Resource, registry *unified.ResourceRegistry) {
if resource == nil || registry == nil {
return
}
resource.MetricsTarget = registry.MetricsTarget(resource.ID)
}
// resourceContractType maps internal unified resource shapes onto the canonical
// REST resource-type contract without exposing legacy aliases.
func resourceContractType(r unified.Resource) unified.ResourceType {
return unified.ContractResourceType(r)
}
// applyResourceContractTypes rewrites Type fields on the response slice so the
// REST API exposes the canonical resource-type contract.
func applyResourceContractTypes(resources []unified.Resource) {
for i := range resources {
resources[i].Type = resourceContractType(resources[i])
}
}
// computeResourceContractByType builds the ByType aggregation using the
// canonical REST resource-type contract. Does not mutate the input slice.
func computeResourceContractByType(resources []unified.Resource) map[unified.ResourceType]int {
m := make(map[unified.ResourceType]int, 8)
for _, r := range resources {
m[resourceContractType(r)]++
}
return m
}
func buildDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
switch unified.CanonicalResourceType(resource.Type) {
case unified.ResourceTypeAgent:
return hostDiscoveryTarget(resource)
case unified.ResourceTypeVM:
return proxmoxGuestDiscoveryTarget(resource, "vm")
case unified.ResourceTypeSystemContainer:
return proxmoxGuestDiscoveryTarget(resource, "system-container")
case unified.ResourceTypePBS:
return hostDiscoveryTarget(resource)
case unified.ResourceTypePMG:
return hostDiscoveryTarget(resource)
case unified.ResourceTypeCeph:
return cephDiscoveryTarget(resource)
case unified.ResourceTypeK8sCluster:
return kubernetesDiscoveryTarget(resource)
case unified.ResourceTypeK8sNode:
return kubernetesDiscoveryTarget(resource)
case unified.ResourceTypePod:
return kubernetesDiscoveryTarget(resource)
case unified.ResourceTypeK8sDeployment:
return kubernetesDiscoveryTarget(resource)
case unified.ResourceTypePhysicalDisk:
return physicalDiskDiscoveryTarget(resource)
default:
return nil
}
}
func cephDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
if resource.Ceph == nil {
return nil
}
hostID := firstNonEmptyTrimmed(
resource.Ceph.FSID,
resource.Name,
resource.ID,
)
if hostID == "" {
return nil
}
return &unified.DiscoveryTarget{
ResourceType: "ceph",
AgentID: hostID,
ResourceID: resource.ID,
Hostname: resource.Name,
}
}
func hostDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
linkedAgentID := ""
agentBacked := hasSource(resource.Sources, unified.SourceAgent) || resource.Agent != nil
if agentBacked {
linkedAgentID = proxmoxLinkedAgentID(resource.Proxmox)
}
if linkedAgentID != "" {
agentBacked = true
}
hostID := firstNonEmptyTrimmed(
agentID(resource.Agent),
linkedAgentID,
proxmoxNodeName(resource.Proxmox),
agentHostname(resource.Agent),
dockerHostname(resource.Docker),
pbsHostname(resource.PBS),
pmgHostname(resource.PMG),
firstResourceHostname(resource),
resource.Name,
resource.ID,
)
if hostID == "" {
return nil
}
return &unified.DiscoveryTarget{
ResourceType: "agent",
AgentID: hostID,
ResourceID: hostID,
Hostname: firstNonEmptyTrimmed(
agentHostname(resource.Agent),
proxmoxNodeName(resource.Proxmox),
dockerHostname(resource.Docker),
pbsHostname(resource.PBS),
pmgHostname(resource.PMG),
firstResourceHostname(resource),
resource.Name,
hostID,
),
}
}
func proxmoxGuestDiscoveryTarget(resource unified.Resource, resourceType string) *unified.DiscoveryTarget {
if resource.Proxmox == nil || resource.Proxmox.NodeName == "" || resource.Proxmox.VMID == 0 {
return nil
}
resourceID := strconv.Itoa(resource.Proxmox.VMID)
hostID := strings.TrimSpace(resource.Proxmox.NodeName)
if hostID == "" || resourceID == "" {
return nil
}
return &unified.DiscoveryTarget{
ResourceType: string(resourceType),
AgentID: hostID,
ResourceID: resourceID,
Hostname: firstNonEmptyTrimmed(
firstResourceHostname(resource),
resource.Name,
resourceID,
),
}
}
func kubernetesDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
if resource.Kubernetes == nil {
return nil
}
resourceType := unified.CanonicalResourceType(resource.Type)
hostID := firstNonEmptyTrimmed(
resource.Kubernetes.AgentID,
resource.Kubernetes.ClusterID,
resource.Kubernetes.ClusterName,
)
if hostID == "" {
return nil
}
resourceID := ""
switch resourceType {
case unified.ResourceTypeK8sCluster:
resourceID = firstNonEmptyTrimmed(
resource.Kubernetes.ClusterID,
resource.Kubernetes.ClusterName,
resource.Name,
)
case unified.ResourceTypeK8sNode:
resourceID = firstNonEmptyTrimmed(
resource.Kubernetes.NodeUID,
resource.Kubernetes.NodeName,
resource.Name,
)
case unified.ResourceTypeK8sDeployment:
resourceID = firstNonEmptyTrimmed(
resource.Kubernetes.DeploymentUID,
kubernetesNamespacedName(resource.Kubernetes.Namespace, resource.Name),
resource.Name,
)
case unified.ResourceTypePod:
resourceID = firstNonEmptyTrimmed(
resource.Kubernetes.PodUID,
kubernetesNamespacedName(resource.Kubernetes.Namespace, resource.Name),
resource.Name,
)
default:
return nil
}
if resourceID == "" {
return nil
}
return &unified.DiscoveryTarget{
ResourceType: string(resourceType),
AgentID: hostID,
ResourceID: resourceID,
Hostname: firstNonEmptyTrimmed(
resource.Kubernetes.ClusterName,
resource.Kubernetes.Context,
resource.Name,
),
}
}
func physicalDiskDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
if resource.PhysicalDisk == nil {
return nil
}
hostID := ""
if resource.Proxmox != nil {
hostID = resource.Proxmox.NodeName
}
if hostID == "" {
hostID = firstNonEmptyTrimmed(firstResourceHostname(resource), resource.Name)
}
if hostID == "" {
return nil
}
return &unified.DiscoveryTarget{
ResourceType: "disk",
AgentID: hostID,
ResourceID: resource.ID,
Hostname: hostID,
}
}
func kubernetesNamespacedName(namespace, name string) string {
ns := strings.TrimSpace(namespace)
n := strings.TrimSpace(name)
if ns == "" {
return n
}
if n == "" {
return ns
}
return ns + "/" + n
}
func firstResourceHostname(resource unified.Resource) string {
for _, hostname := range resource.Identity.Hostnames {
trimmed := strings.TrimSpace(hostname)
if trimmed != "" {
return trimmed
}
}
return ""
}
func firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func hasSource(sources []unified.DataSource, target unified.DataSource) bool {
for _, source := range sources {
if source == target {
return true
}
}
return false
}
func agentID(agent *unified.AgentData) string {
if agent == nil {
return ""
}
return agent.AgentID
}
func agentHostname(agent *unified.AgentData) string {
if agent == nil {
return ""
}
return agent.Hostname
}
func proxmoxLinkedAgentID(proxmox *unified.ProxmoxData) string {
if proxmox == nil {
return ""
}
return proxmox.LinkedAgentID
}
func proxmoxNodeName(proxmox *unified.ProxmoxData) string {
if proxmox == nil {
return ""
}
return proxmox.NodeName
}
func dockerHostname(docker *unified.DockerData) string {
if docker == nil {
return ""
}
return docker.Hostname
}
func pbsHostname(pbs *unified.PBSData) string {
if pbs == nil {
return ""
}
return pbs.Hostname
}
func pmgHostname(pmg *unified.PMGData) string {
if pmg == nil {
return ""
}
return pmg.Hostname
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ToLower(part))
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func parseIntDefault(raw string, def int) int {
if raw == "" {
return def
}
val, err := strconv.Atoi(raw)
if err != nil {
return def
}
return val
}
// getUserID attempts to resolve the user ID for auditing.
func getUserID(r *http.Request) string {
return auth.GetUser(r.Context())
}