mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-12 04:06:37 +08:00
feat(monitoring): add node/tunnel metrics, service monitors, and health checks
- Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods - Implement metrics ingestion service with per-minute bucket aggregation - Add health checker for node connectivity monitoring - Wire node metrics from WebSocket SystemInfo messages - Add tunnel metrics ingestion from flow upload endpoint - Create monitoring REST API endpoints for nodes, tunnels, services - Implement service monitor CRUD and execution (TCP/ICMP checks) - Add MonitorPermission for non-admin access control - Create frontend monitor page with node/tunnel/service views - Add tunnel metrics ingestion from agent flow reports - Include schema migration for tunnel_metric unique index - Fix tunnel entry port conflict validation to use transaction Entire-Checkpoint: 030821a7c8e3
This commit is contained in:
@@ -47,6 +47,10 @@ type UserGroupBackup = model.UserGroupBackup
|
||||
type PermissionBackup = model.PermissionBackup
|
||||
type PermissionGrantBackup = model.PermissionGrantBackup
|
||||
type ImportResult = model.ImportResult
|
||||
type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -176,12 +180,17 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.UserGroupUser{},
|
||||
&model.GroupPermission{},
|
||||
&model.GroupPermissionGrant{},
|
||||
&model.MonitorPermission{},
|
||||
&model.ViteConfig{},
|
||||
&model.PeerShare{},
|
||||
&model.PeerShareRuntime{},
|
||||
&model.FederationTunnelBinding{},
|
||||
&model.Announcement{},
|
||||
&model.SchemaVersion{},
|
||||
&model.NodeMetric{},
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
@@ -394,6 +403,24 @@ func (r *Repository) ListConfigs() (map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetConfigsByNames(names []string) (map[string]string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var configs []model.ViteConfig
|
||||
if err := r.db.Select("name", "value").Where("name IN ?", names).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(configs))
|
||||
for _, c := range configs {
|
||||
result[c.Name] = c.Value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
@@ -2689,12 +2716,13 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 5
|
||||
const currentSchemaVersion = 6
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
|
||||
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
|
||||
var migrateTunnelMetricBucketUniqueIndexFn = migrateTunnelMetricBucketUniqueIndex
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2764,6 +2792,12 @@ func migrateSchema(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if ver < 6 {
|
||||
if err := migrateTunnelMetricBucketUniqueIndexFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -2867,6 +2901,130 @@ func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTunnelMetricBucketUniqueIndex(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&model.TunnelMetric{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// Only do the heavier dedupe work when needed.
|
||||
var dupGroups int64
|
||||
q := `
|
||||
SELECT COUNT(1) AS cnt
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
`
|
||||
if err := tx.Raw(q).Scan(&dupGroups).Error; err != nil {
|
||||
return fmt.Errorf("inspect tunnel_metric duplicates: %w", err)
|
||||
}
|
||||
|
||||
if dupGroups > 0 {
|
||||
switch tx.Dialector.Name() {
|
||||
case "postgres":
|
||||
sql := `
|
||||
WITH agg AS (
|
||||
SELECT MIN(id) AS keep_id,
|
||||
tunnel_id,
|
||||
node_id,
|
||||
timestamp,
|
||||
SUM(bytes_in) AS bytes_in,
|
||||
SUM(bytes_out) AS bytes_out,
|
||||
SUM(connections) AS connections,
|
||||
SUM(errors) AS errors,
|
||||
AVG(avg_latency_ms) AS avg_latency_ms
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
), updated AS (
|
||||
UPDATE tunnel_metric tm
|
||||
SET bytes_in = agg.bytes_in,
|
||||
bytes_out = agg.bytes_out,
|
||||
connections = agg.connections,
|
||||
errors = agg.errors,
|
||||
avg_latency_ms = agg.avg_latency_ms
|
||||
FROM agg
|
||||
WHERE tm.id = agg.keep_id
|
||||
RETURNING tm.id
|
||||
)
|
||||
DELETE FROM tunnel_metric tm
|
||||
USING agg
|
||||
WHERE tm.tunnel_id = agg.tunnel_id
|
||||
AND tm.node_id = agg.node_id
|
||||
AND tm.timestamp = agg.timestamp
|
||||
AND tm.id <> agg.keep_id
|
||||
`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("dedupe tunnel_metric buckets: %w", err)
|
||||
}
|
||||
default:
|
||||
// SQLite (and other) path.
|
||||
if err := tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error; err != nil {
|
||||
return fmt.Errorf("prepare tunnel_metric dedupe table: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
CREATE TEMP TABLE tunnel_metric_dedupe AS
|
||||
SELECT MIN(id) AS keep_id,
|
||||
tunnel_id,
|
||||
node_id,
|
||||
timestamp,
|
||||
SUM(bytes_in) AS bytes_in,
|
||||
SUM(bytes_out) AS bytes_out,
|
||||
SUM(connections) AS connections,
|
||||
SUM(errors) AS errors,
|
||||
AVG(avg_latency_ms) AS avg_latency_ms
|
||||
FROM tunnel_metric
|
||||
GROUP BY tunnel_id, node_id, timestamp
|
||||
HAVING COUNT(*) > 1
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("build tunnel_metric dedupe table: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE tunnel_metric
|
||||
SET bytes_in = (SELECT bytes_in FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
bytes_out = (SELECT bytes_out FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
connections = (SELECT connections FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
errors = (SELECT errors FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id),
|
||||
avg_latency_ms = (SELECT avg_latency_ms FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id)
|
||||
WHERE id IN (SELECT keep_id FROM tunnel_metric_dedupe)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("update tunnel_metric deduped rows: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
DELETE FROM tunnel_metric
|
||||
WHERE id IN (
|
||||
SELECT tm.id
|
||||
FROM tunnel_metric tm
|
||||
JOIN tunnel_metric_dedupe d
|
||||
ON tm.tunnel_id = d.tunnel_id
|
||||
AND tm.node_id = d.node_id
|
||||
AND tm.timestamp = d.timestamp
|
||||
WHERE tm.id <> d.keep_id
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("delete tunnel_metric duplicates: %w", err)
|
||||
}
|
||||
_ = tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error
|
||||
}
|
||||
}
|
||||
|
||||
// Uniqueness is required for safe upsert on (tunnel_id, node_id, timestamp).
|
||||
if err := tx.Exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uidx_tunnel_metric_bucket ON tunnel_metric(tunnel_id, node_id, timestamp)`,
|
||||
).Error; err != nil {
|
||||
return fmt.Errorf("create tunnel_metric unique index: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
@@ -3140,3 +3298,331 @@ var osMkdirAll = func(path string) error {
|
||||
|
||||
// Suppress unused import warning for log
|
||||
var _ = log.Printf
|
||||
|
||||
func (r *Repository) InsertNodeMetric(m *model.NodeMetric) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertNodeMetricBatch(metrics []*model.NodeMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(metrics, 100).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var metrics []model.NodeMetric
|
||||
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||
Order("timestamp DESC").
|
||||
Limit(5000).
|
||||
Find(&metrics).Error
|
||||
if len(metrics) > 1 {
|
||||
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
|
||||
metrics[i], metrics[j] = metrics[j], metrics[i]
|
||||
}
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetLatestNodeMetric(nodeID int64) (*model.NodeMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var m model.NodeMetric
|
||||
err := r.db.Where("node_id = ?", nodeID).Order("timestamp DESC").First(&m).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PruneNodeMetrics(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.NodeMetric{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertTunnelMetric(m *model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) InsertTunnelMetricBatch(metrics []*model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(metrics, 100).Error
|
||||
}
|
||||
|
||||
// UpsertTunnelMetricBuckets adds the provided metric deltas into per-minute buckets.
|
||||
// Requires a unique index on (tunnel_id, node_id, timestamp) for safe upserts.
|
||||
func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) error {
|
||||
if r == nil || r.db == nil || len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Postgres rejects a single INSERT ... ON CONFLICT when the input contains
|
||||
// duplicate conflict keys. Pre-aggregate within this batch to keep inserts safe.
|
||||
type bucketKey struct {
|
||||
tunnelID int64
|
||||
nodeID int64
|
||||
timestamp int64
|
||||
}
|
||||
|
||||
agg := make(map[bucketKey]*model.TunnelMetric, len(metrics))
|
||||
for _, m := range metrics {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.TunnelID <= 0 || m.NodeID <= 0 || m.Timestamp <= 0 {
|
||||
continue
|
||||
}
|
||||
if m.BytesIn == 0 && m.BytesOut == 0 && m.Connections == 0 && m.Errors == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
k := bucketKey{tunnelID: m.TunnelID, nodeID: m.NodeID, timestamp: m.Timestamp}
|
||||
if existing, ok := agg[k]; ok {
|
||||
existing.BytesIn += m.BytesIn
|
||||
existing.BytesOut += m.BytesOut
|
||||
existing.Connections += m.Connections
|
||||
existing.Errors += m.Errors
|
||||
if existing.AvgLatencyMs == 0 && m.AvgLatencyMs != 0 {
|
||||
existing.AvgLatencyMs = m.AvgLatencyMs
|
||||
}
|
||||
continue
|
||||
}
|
||||
cp := *m
|
||||
agg[k] = &cp
|
||||
}
|
||||
if len(agg) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([]*model.TunnelMetric, 0, len(agg))
|
||||
for _, v := range agg {
|
||||
rows = append(rows, v)
|
||||
}
|
||||
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
|
||||
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
|
||||
"connections": gorm.Expr("connections + excluded.connections"),
|
||||
"errors": gorm.Expr("errors + excluded.errors"),
|
||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||
}),
|
||||
}).CreateInBatches(rows, 100).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelMetrics(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var metrics []model.TunnelMetric
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp DESC").
|
||||
Limit(5000).
|
||||
Find(&metrics).Error
|
||||
if len(metrics) > 1 {
|
||||
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
|
||||
metrics[i], metrics[j] = metrics[j], metrics[i]
|
||||
}
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
// GetTunnelMetricsAggregated returns tunnel-level aggregated series (one point per timestamp).
|
||||
// Storage remains per (tunnel_id, node_id, timestamp) for future drill-down.
|
||||
func (r *Repository) GetTunnelMetricsAggregated(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var metrics []model.TunnelMetric
|
||||
err := r.db.Model(&model.TunnelMetric{}).
|
||||
Select(
|
||||
"tunnel_id, 0 AS node_id, timestamp, "+
|
||||
"SUM(bytes_in) AS bytes_in, "+
|
||||
"SUM(bytes_out) AS bytes_out, "+
|
||||
"SUM(connections) AS connections, "+
|
||||
"SUM(errors) AS errors, "+
|
||||
"AVG(avg_latency_ms) AS avg_latency_ms",
|
||||
).
|
||||
Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Group("tunnel_id, timestamp").
|
||||
Order("timestamp ASC").
|
||||
Limit(5000).
|
||||
Scan(&metrics).Error
|
||||
if metrics == nil {
|
||||
metrics = make([]model.TunnelMetric, 0)
|
||||
}
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func (r *Repository) PruneTunnelMetrics(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelMetric{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListServiceMonitors() ([]model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var monitors []model.ServiceMonitor
|
||||
err := r.db.Order("id ASC").Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListEnabledServiceMonitors() ([]model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var monitors []model.ServiceMonitor
|
||||
err := r.db.Where("enabled = 1 AND type IN (?)", []string{"tcp", "icmp"}).Order("id ASC").Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetServiceMonitor(id int64) (*model.ServiceMonitor, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var m model.ServiceMonitor
|
||||
err := r.db.First(&m, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateServiceMonitor(m *model.ServiceMonitor) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateServiceMonitor(m *model.ServiceMonitor) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Save(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteServiceMonitor(id int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep API/UI semantics simple: deleting a monitor also deletes its history.
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("monitor_id = ?", id).Delete(&model.ServiceMonitorResult{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.ServiceMonitor{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) InsertServiceMonitorResult(result *model.ServiceMonitorResult) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(result).Error
|
||||
}
|
||||
|
||||
func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var results []model.ServiceMonitorResult
|
||||
err := r.db.Where("monitor_id = ?", monitorID).
|
||||
Order("timestamp DESC").
|
||||
Limit(limit).
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
|
||||
// This is intended for list rendering (avoid N+1 queries).
|
||||
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.ServiceMonitorResult
|
||||
|
||||
// Prefer a window-function query (works on modern SQLite + Postgres).
|
||||
q1 := `
|
||||
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM service_monitor_result
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY monitor_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q1).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback: just return newest rows (best-effort). This avoids hard failure on older SQLite builds.
|
||||
// Note: This may not include all monitors if the table is extremely large and skewed.
|
||||
results = nil
|
||||
q2 := `
|
||||
SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message
|
||||
FROM service_monitor_result
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT 5000
|
||||
`
|
||||
err := r.db.Raw(q2).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.ServiceMonitorResult, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.MonitorID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.MonitorID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.MonitorID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
// Keep response stable for the frontend.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].MonitorID < out[j].MonitorID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PruneServiceMonitorResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.ServiceMonitorResult{}).Error
|
||||
}
|
||||
|
||||
@@ -57,6 +57,37 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -111,6 +142,26 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var ports []model.ForwardPort
|
||||
err := tx.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardPortRecord, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
inIP := ""
|
||||
if p.InIP.Valid {
|
||||
inIP = p.InIP.String
|
||||
}
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
@@ -130,6 +181,25 @@ func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentFo
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New("database unavailable")
|
||||
}
|
||||
if nodeID <= 0 || port <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := tx.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
|
||||
@@ -161,6 +161,64 @@ func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// MapForwardIDsToTunnelIDs returns a mapping from forward.id to forward.tunnel_id.
|
||||
// Missing forward IDs are omitted from the returned map.
|
||||
func (r *Repository) MapForwardIDsToTunnelIDs(forwardIDs []int64) (map[int64]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(forwardIDs) == 0 {
|
||||
return map[int64]int64{}, nil
|
||||
}
|
||||
|
||||
// Deduplicate and filter invalid IDs.
|
||||
ids := make([]int64, 0, len(forwardIDs))
|
||||
seen := make(map[int64]struct{}, len(forwardIDs))
|
||||
for _, id := range forwardIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[int64]int64{}, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
}
|
||||
|
||||
out := make(map[int64]int64, len(ids))
|
||||
const chunkSize = 500
|
||||
for start := 0; start < len(ids); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := r.db.Model(&model.Forward{}).
|
||||
Select("id", "tunnel_id").
|
||||
Where("id IN ?", ids[start:end]).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ID <= 0 || r.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
out[r.ID] = r.TunnelID
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var nodes []model.Node
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
return nodes, err
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *Repository) InsertMonitorPermission(userID int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
row := model.MonitorPermission{UserID: userID, CreatedTime: now}
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteMonitorPermission(userID int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("user_id = ?", userID).Delete(&model.MonitorPermission{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) HasMonitorPermission(userID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.MonitorPermission{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListMonitorPermissions() ([]model.MonitorPermission, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.MonitorPermission
|
||||
err := r.db.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) ListMonitorTunnels() ([]model.Tunnel, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var tunnels []model.Tunnel
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&tunnels).Error
|
||||
return tunnels, err
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestGetTunnelMetricsAggregatedSumsAcrossNodes(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
if err := r.InsertTunnelMetric(&model.TunnelMetric{
|
||||
TunnelID: 1,
|
||||
NodeID: 1,
|
||||
Timestamp: ts,
|
||||
BytesIn: 100,
|
||||
BytesOut: 200,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tunnel metric n1: %v", err)
|
||||
}
|
||||
if err := r.InsertTunnelMetric(&model.TunnelMetric{
|
||||
TunnelID: 1,
|
||||
NodeID: 2,
|
||||
Timestamp: ts,
|
||||
BytesIn: 300,
|
||||
BytesOut: 400,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tunnel metric n2: %v", err)
|
||||
}
|
||||
|
||||
metrics, err := r.GetTunnelMetricsAggregated(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get aggregated tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected 1 aggregated point, got %d", len(metrics))
|
||||
}
|
||||
if metrics[0].Timestamp != ts {
|
||||
t.Fatalf("expected timestamp %d, got %d", ts, metrics[0].Timestamp)
|
||||
}
|
||||
if metrics[0].BytesIn != 400 {
|
||||
t.Fatalf("expected bytesIn 400, got %d", metrics[0].BytesIn)
|
||||
}
|
||||
if metrics[0].BytesOut != 600 {
|
||||
t.Fatalf("expected bytesOut 600, got %d", metrics[0].BytesOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertTunnelMetricBucketsAggregatesDuplicateKeysInBatch(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
items := []*model.TunnelMetric{
|
||||
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 10, BytesOut: 20},
|
||||
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 30, BytesOut: 40},
|
||||
}
|
||||
if err := r.UpsertTunnelMetricBuckets(items); err != nil {
|
||||
t.Fatalf("upsert buckets: %v", err)
|
||||
}
|
||||
|
||||
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 stored row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].BytesIn != 40 {
|
||||
t.Fatalf("expected bytesIn 40, got %d", rows[0].BytesIn)
|
||||
}
|
||||
if rows[0].BytesOut != 60 {
|
||||
t.Fatalf("expected bytesOut 60, got %d", rows[0].BytesOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ts := time.Now().UnixMilli()
|
||||
|
||||
const workers = 20
|
||||
const perWorkerIn = int64(5)
|
||||
const perWorkerOut = int64(7)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = r.UpsertTunnelMetricBuckets([]*model.TunnelMetric{{
|
||||
TunnelID: 1,
|
||||
NodeID: 1,
|
||||
Timestamp: ts,
|
||||
BytesIn: perWorkerIn,
|
||||
BytesOut: perWorkerOut,
|
||||
}})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 stored row, got %d", len(rows))
|
||||
}
|
||||
|
||||
wantIn := int64(workers) * perWorkerIn
|
||||
wantOut := int64(workers) * perWorkerOut
|
||||
if rows[0].BytesIn != wantIn {
|
||||
t.Fatalf("expected bytesIn %d, got %d", wantIn, rows[0].BytesIn)
|
||||
}
|
||||
if rows[0].BytesOut != wantOut {
|
||||
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user