Skip to content

Commit e8f1cb9

Browse files
committed
feat: add network analytics dashboard with traffic statistics
2 parents 797e2b2 + 6e81e00 commit e8f1cb9

19 files changed

Lines changed: 1777 additions & 2093 deletions

backend/internal/database/database.go

Lines changed: 482 additions & 4 deletions
Large diffs are not rendered by default.

backend/internal/handlers/handlers.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,3 +703,289 @@ func (h *Handlers) GetDNSNameservers(c *gin.Context) {
703703

704704
c.JSON(http.StatusOK, nameservers)
705705
}
706+
707+
// parseTimeRange extracts and validates start/end query params.
708+
// Defaults: start = 1 hour ago, end = now.
709+
func (h *Handlers) parseTimeRange(c *gin.Context) (time.Time, time.Time, error) {
710+
var startTime, endTime time.Time
711+
var err error
712+
713+
if s := c.Query("start"); s != "" {
714+
startTime, err = time.Parse(time.RFC3339, s)
715+
if err != nil {
716+
return time.Time{}, time.Time{}, fmt.Errorf("invalid start time: %w", err)
717+
}
718+
} else {
719+
startTime = time.Now().Add(-1 * time.Hour)
720+
}
721+
722+
if e := c.Query("end"); e != "" {
723+
endTime, err = time.Parse(time.RFC3339, e)
724+
if err != nil {
725+
return time.Time{}, time.Time{}, fmt.Errorf("invalid end time: %w", err)
726+
}
727+
} else {
728+
endTime = time.Now()
729+
}
730+
731+
if endTime.Before(startTime) {
732+
return time.Time{}, time.Time{}, fmt.Errorf("end time before start time")
733+
}
734+
735+
return startTime, endTime, nil
736+
}
737+
738+
// GetStatsOverview returns network-wide statistics for a time range
739+
func (h *Handlers) GetStatsOverview(c *gin.Context) {
740+
if h.store == nil {
741+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not configured"})
742+
return
743+
}
744+
745+
startTime, endTime, err := h.parseTimeRange(c)
746+
if err != nil {
747+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
748+
return
749+
}
750+
751+
var buckets []database.TrafficStats
752+
source := "database"
753+
754+
// Try rolling cache first for recent data
755+
duration := endTime.Sub(startTime)
756+
if h.poller != nil && duration <= time.Hour {
757+
cache := h.poller.GetRollingCache()
758+
if cache.HasDataFor(startTime, endTime) {
759+
buckets = cache.GetTrafficStats(startTime, endTime)
760+
source = "cache"
761+
}
762+
}
763+
764+
// Fall back to database
765+
if len(buckets) == 0 {
766+
ctx, cancel := context.WithTimeout(c.Request.Context(), AggregationQueryTimeout)
767+
defer cancel()
768+
769+
buckets, err = h.store.GetTrafficStats(ctx, startTime, endTime)
770+
if err != nil {
771+
log.Printf("ERROR GetStatsOverview: %v", err)
772+
c.JSON(http.StatusInternalServerError, gin.H{
773+
"error": "Failed to fetch traffic stats",
774+
"message": err.Error(),
775+
})
776+
return
777+
}
778+
source = "database"
779+
}
780+
781+
// Aggregate buckets into summary
782+
var tcpBytes, udpBytes, otherProtoBytes int64
783+
var virtualBytes, subnetBytes, physicalBytes int64
784+
var totalFlows, maxUniquePairs int64
785+
for _, b := range buckets {
786+
tcpBytes += b.TCPBytes
787+
udpBytes += b.UDPBytes
788+
otherProtoBytes += b.OtherProtoBytes
789+
virtualBytes += b.VirtualBytes
790+
subnetBytes += b.SubnetBytes
791+
physicalBytes += b.PhysicalBytes
792+
totalFlows += b.TotalFlows
793+
if b.UniquePairs > maxUniquePairs {
794+
maxUniquePairs = b.UniquePairs
795+
}
796+
}
797+
798+
c.JSON(http.StatusOK, gin.H{
799+
"summary": gin.H{
800+
"tcpBytes": tcpBytes,
801+
"udpBytes": udpBytes,
802+
"otherProtoBytes": otherProtoBytes,
803+
"virtualBytes": virtualBytes,
804+
"subnetBytes": subnetBytes,
805+
"physicalBytes": physicalBytes,
806+
"totalFlows": totalFlows,
807+
"uniquePairs": maxUniquePairs,
808+
},
809+
"buckets": buckets,
810+
"metadata": gin.H{
811+
"start": startTime,
812+
"end": endTime,
813+
"bucketCount": len(buckets),
814+
"source": source,
815+
},
816+
})
817+
}
818+
819+
// resolveNodeName returns a human-readable name for a node ID using the device cache.
820+
func (h *Handlers) resolveNodeName(nodeID string) string {
821+
if h.poller == nil {
822+
return ""
823+
}
824+
cache := h.poller.GetDeviceCache()
825+
if entry := cache.GetDevice(nodeID); entry != nil {
826+
// Prefer hostname, fall back to full name
827+
if entry.Hostname != "" {
828+
return entry.Hostname
829+
}
830+
return entry.Name
831+
}
832+
return ""
833+
}
834+
835+
// GetTopTalkers returns the top N nodes by total traffic
836+
func (h *Handlers) GetTopTalkers(c *gin.Context) {
837+
if h.store == nil {
838+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not configured"})
839+
return
840+
}
841+
842+
startTime, endTime, err := h.parseTimeRange(c)
843+
if err != nil {
844+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
845+
return
846+
}
847+
848+
limit := 10
849+
if l := c.Query("limit"); l != "" {
850+
if _, err := fmt.Sscanf(l, "%d", &limit); err != nil || limit <= 0 {
851+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
852+
return
853+
}
854+
}
855+
if limit > 100 {
856+
limit = 100
857+
}
858+
859+
ctx, cancel := context.WithTimeout(c.Request.Context(), DefaultQueryTimeout)
860+
defer cancel()
861+
862+
talkers, err := h.store.GetTopTalkers(ctx, startTime, endTime, limit)
863+
if err != nil {
864+
log.Printf("ERROR GetTopTalkers: %v", err)
865+
c.JSON(http.StatusInternalServerError, gin.H{
866+
"error": "Failed to fetch top talkers",
867+
"message": err.Error(),
868+
})
869+
return
870+
}
871+
872+
// Enrich with device names
873+
enriched := make([]gin.H, 0, len(talkers))
874+
for _, t := range talkers {
875+
enriched = append(enriched, gin.H{
876+
"nodeId": t.NodeID,
877+
"displayName": h.resolveNodeName(t.NodeID),
878+
"txBytes": t.TxBytes,
879+
"rxBytes": t.RxBytes,
880+
"totalBytes": t.TotalBytes,
881+
})
882+
}
883+
884+
c.JSON(http.StatusOK, gin.H{
885+
"talkers": enriched,
886+
"metadata": gin.H{
887+
"start": startTime,
888+
"end": endTime,
889+
"limit": limit,
890+
"count": len(enriched),
891+
},
892+
})
893+
}
894+
895+
// GetTopPairs returns the top N node pairs by total traffic
896+
func (h *Handlers) GetTopPairs(c *gin.Context) {
897+
if h.store == nil {
898+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not configured"})
899+
return
900+
}
901+
902+
startTime, endTime, err := h.parseTimeRange(c)
903+
if err != nil {
904+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
905+
return
906+
}
907+
908+
limit := 10
909+
if l := c.Query("limit"); l != "" {
910+
if _, err := fmt.Sscanf(l, "%d", &limit); err != nil || limit <= 0 {
911+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
912+
return
913+
}
914+
}
915+
if limit > 100 {
916+
limit = 100
917+
}
918+
919+
ctx, cancel := context.WithTimeout(c.Request.Context(), DefaultQueryTimeout)
920+
defer cancel()
921+
922+
pairs, err := h.store.GetTopPairs(ctx, startTime, endTime, limit)
923+
if err != nil {
924+
log.Printf("ERROR GetTopPairs: %v", err)
925+
c.JSON(http.StatusInternalServerError, gin.H{
926+
"error": "Failed to fetch top pairs",
927+
"message": err.Error(),
928+
})
929+
return
930+
}
931+
932+
// Enrich with device names
933+
enriched := make([]gin.H, 0, len(pairs))
934+
for _, p := range pairs {
935+
enriched = append(enriched, gin.H{
936+
"srcNodeId": p.SrcNodeID,
937+
"srcDisplayName": h.resolveNodeName(p.SrcNodeID),
938+
"dstNodeId": p.DstNodeID,
939+
"dstDisplayName": h.resolveNodeName(p.DstNodeID),
940+
"txBytes": p.TxBytes,
941+
"rxBytes": p.RxBytes,
942+
"totalBytes": p.TotalBytes,
943+
"flowCount": p.FlowCount,
944+
})
945+
}
946+
947+
c.JSON(http.StatusOK, gin.H{
948+
"pairs": enriched,
949+
"metadata": gin.H{
950+
"start": startTime,
951+
"end": endTime,
952+
"limit": limit,
953+
"count": len(enriched),
954+
},
955+
})
956+
}
957+
958+
// GetNodeDetailStats returns detailed stats for a specific node
959+
func (h *Handlers) GetNodeDetailStats(c *gin.Context) {
960+
if h.store == nil {
961+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not configured"})
962+
return
963+
}
964+
965+
nodeID := c.Param("id")
966+
if nodeID == "" {
967+
c.JSON(http.StatusBadRequest, gin.H{"error": "node ID required"})
968+
return
969+
}
970+
971+
startTime, endTime, err := h.parseTimeRange(c)
972+
if err != nil {
973+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
974+
return
975+
}
976+
977+
ctx, cancel := context.WithTimeout(c.Request.Context(), DefaultQueryTimeout)
978+
defer cancel()
979+
980+
stats, err := h.store.GetNodeStats(ctx, nodeID, startTime, endTime)
981+
if err != nil {
982+
log.Printf("ERROR GetNodeDetailStats: %v", err)
983+
c.JSON(http.StatusInternalServerError, gin.H{
984+
"error": "Failed to fetch node stats",
985+
"message": err.Error(),
986+
})
987+
return
988+
}
989+
990+
c.JSON(http.StatusOK, stats)
991+
}

0 commit comments

Comments
 (0)