mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Fix mock metrics history and guest drawer controls
This commit is contained in:
parent
6e2cae2363
commit
c75972d57c
7 changed files with 158 additions and 33 deletions
|
|
@ -314,13 +314,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
<Show when={activeTab() === 'history'}>
|
||||
<div class="space-y-6">
|
||||
{/* Toolbar: Range and View Toggle */}
|
||||
<div class="flex flex-col gap-4 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-xl border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Controls</span>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
<div class="flex flex-wrap items-center gap-2 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">View</span>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('unified')}
|
||||
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'unified'
|
||||
class={`px-2 py-0.5 text-[10px] font-semibold rounded-md transition-all ${viewMode() === 'unified'
|
||||
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
|
|
@ -329,7 +329,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('split')}
|
||||
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'split'
|
||||
class={`px-2 py-0.5 text-[10px] font-semibold rounded-md transition-all ${viewMode() === 'split'
|
||||
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
|
|
@ -339,13 +339,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700/30">
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Range</span>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
<div class="flex items-center gap-2 sm:ml-auto">
|
||||
<span class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Range</span>
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
|
||||
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
|
||||
<button
|
||||
onClick={() => setHistoryRange(r)}
|
||||
class={`px-4 py-1.5 text-xs font-medium rounded-md transition-all ${historyRange() === r
|
||||
class={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${historyRange() === r
|
||||
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface RingBuffer {
|
|||
}
|
||||
|
||||
// Configuration
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours (to support all time ranges)
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (to support all time ranges)
|
||||
const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~2880 points
|
||||
const STORAGE_KEY = 'pulse_metrics_history';
|
||||
|
|
|
|||
|
|
@ -651,7 +651,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
|||
}
|
||||
|
||||
diag := &APITokenDiagnostic{
|
||||
Enabled: cfg.APITokenEnabled,
|
||||
Enabled: cfg.HasAPITokens(),
|
||||
TokenCount: len(cfg.APITokens),
|
||||
}
|
||||
|
||||
|
|
@ -682,9 +682,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
|||
diag.RecommendTokenSetup = len(cfg.APITokens) == 0
|
||||
diag.RecommendTokenRotation = envTokens || legacyToken
|
||||
|
||||
if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 {
|
||||
appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.")
|
||||
} else if diag.RecommendTokenSetup {
|
||||
if diag.RecommendTokenSetup {
|
||||
appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ func IsPasswordHashed(password string) bool {
|
|||
type Config struct {
|
||||
// Server settings
|
||||
BackendPort int
|
||||
BackendHost string
|
||||
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
|
||||
ConfigPath string
|
||||
DataPath string
|
||||
|
|
@ -556,6 +557,7 @@ func Load() (*Config, error) {
|
|||
// Initialize config with defaults
|
||||
cfg := &Config{
|
||||
BackendPort: 3000,
|
||||
BackendHost: "0.0.0.0",
|
||||
FrontendPort: 7655,
|
||||
ConfigPath: dataDir,
|
||||
DataPath: dataDir,
|
||||
|
|
@ -1011,6 +1013,11 @@ func Load() (*Config, error) {
|
|||
cfg.EnvOverrides["ALLOWED_ORIGINS"] = true
|
||||
}
|
||||
|
||||
if backendHost := utils.GetenvTrim("BACKEND_HOST"); backendHost != "" {
|
||||
cfg.BackendHost = backendHost
|
||||
cfg.EnvOverrides["BACKEND_HOST"] = true
|
||||
}
|
||||
|
||||
if sshPort := utils.GetenvTrim("SSH_PORT"); sshPort != "" {
|
||||
if port, err := strconv.Atoi(sshPort); err == nil {
|
||||
if port <= 0 || port > 65535 {
|
||||
|
|
|
|||
|
|
@ -462,6 +462,11 @@ func (m *Monitor) startMockMetricsSampler(ctx context.Context) {
|
|||
m.mu.Unlock()
|
||||
|
||||
state := mock.GetMockState()
|
||||
if m.metricsStore != nil {
|
||||
if err := m.metricsStore.Clear(); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to clear metrics store before mock seeding")
|
||||
}
|
||||
}
|
||||
seedMockMetricsHistory(m.metricsHistory, m.metricsStore, state, time.Now(), seedDuration, cfg.SampleInterval)
|
||||
recordMockStateToMetricsHistory(m.metricsHistory, m.metricsStore, state, time.Now())
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
)
|
||||
|
||||
func TestSeedMockMetricsHistory_PopulatesSeries(t *testing.T) {
|
||||
|
|
@ -99,3 +100,52 @@ func TestSeedMockMetricsHistory_PopulatesSeries(t *testing.T) {
|
|||
t.Fatalf("expected last docker cpu point to match current, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMockMetricsHistory_SeedsMetricsStore(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
state := models.StateSnapshot{
|
||||
Nodes: []models.Node{
|
||||
{
|
||||
ID: "node-1",
|
||||
Status: "online",
|
||||
CPU: 0.33,
|
||||
Memory: models.Memory{Usage: 62, Total: 128 * 1024 * 1024 * 1024},
|
||||
Disk: models.Disk{Usage: 41, Total: 1024, Used: 512},
|
||||
},
|
||||
},
|
||||
VMs: []models.VM{
|
||||
{
|
||||
ID: "vm-100",
|
||||
Status: "running",
|
||||
CPU: 0.21,
|
||||
Memory: models.Memory{Usage: 47, Total: 8 * 1024 * 1024 * 1024},
|
||||
Disk: models.Disk{Usage: 28, Total: 1024, Used: 256},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := metrics.DefaultConfig(t.TempDir())
|
||||
cfg.RetentionRaw = 90 * 24 * time.Hour
|
||||
cfg.RetentionMinute = 90 * 24 * time.Hour
|
||||
cfg.RetentionHourly = 90 * 24 * time.Hour
|
||||
cfg.RetentionDaily = 90 * 24 * time.Hour
|
||||
cfg.WriteBufferSize = 500
|
||||
|
||||
store, err := metrics.NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create metrics store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
mh := NewMetricsHistory(1000, 7*24*time.Hour)
|
||||
seedMockMetricsHistory(mh, store, state, now, 7*24*time.Hour, time.Minute)
|
||||
|
||||
points, err := store.Query("vm", "vm-100", "cpu", now.Add(-7*24*time.Hour), now, 3600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query metrics store: %v", err)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
t.Fatal("expected metrics store to have seeded points for 7d range")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -526,13 +527,33 @@ func (s *Store) runRollup() {
|
|||
func (s *Store) rollupTier(fromTier, toTier Tier, bucketSize, minAge time.Duration) {
|
||||
cutoff := time.Now().Add(-minAge).Unix()
|
||||
bucketSecs := int64(bucketSize.Seconds())
|
||||
if bucketSecs <= 0 {
|
||||
return
|
||||
}
|
||||
cutoffBucket := (cutoff / bucketSecs) * bucketSecs
|
||||
if cutoffBucket <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
metaKey := fmt.Sprintf("rollup:%s:%s", fromTier, toTier)
|
||||
lastBucket, ok := s.getMetaInt(metaKey)
|
||||
if !ok {
|
||||
if maxTs, ok := s.getMaxTimestampForTier(toTier); ok {
|
||||
lastBucket = (maxTs / bucketSecs) * bucketSecs
|
||||
_ = s.setMetaInt(metaKey, lastBucket)
|
||||
}
|
||||
}
|
||||
|
||||
if cutoffBucket <= lastBucket {
|
||||
return
|
||||
}
|
||||
|
||||
// Find distinct resource/metric combinations that need rollup
|
||||
rows, err := s.db.Query(`
|
||||
SELECT DISTINCT resource_type, resource_id, metric_type
|
||||
FROM metrics
|
||||
WHERE tier = ? AND timestamp < ?
|
||||
`, string(fromTier), cutoff)
|
||||
WHERE tier = ? AND timestamp >= ? AND timestamp < ?
|
||||
`, string(fromTier), lastBucket, cutoffBucket)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("tier", string(fromTier)).Msg("Failed to find rollup candidates")
|
||||
return
|
||||
|
|
@ -562,12 +583,19 @@ func (s *Store) rollupTier(fromTier, toTier Tier, bucketSize, minAge time.Durati
|
|||
|
||||
// Process each candidate
|
||||
for _, c := range candidates {
|
||||
s.rollupCandidate(c.resourceType, c.resourceID, c.metricType, fromTier, toTier, bucketSecs, cutoff)
|
||||
s.rollupCandidate(c.resourceType, c.resourceID, c.metricType, fromTier, toTier, bucketSecs, lastBucket, cutoffBucket)
|
||||
}
|
||||
|
||||
if err := s.setMetaInt(metaKey, cutoffBucket); err != nil {
|
||||
log.Warn().Err(err).Str("tier", string(fromTier)).Msg("Failed to persist rollup checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// rollupCandidate aggregates a single resource/metric from one tier to another
|
||||
func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fromTier, toTier Tier, bucketSecs, cutoff int64) {
|
||||
func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fromTier, toTier Tier, bucketSecs, startTs, endTs int64) {
|
||||
if startTs >= endTs {
|
||||
return
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -588,9 +616,9 @@ func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fro
|
|||
?
|
||||
FROM metrics
|
||||
WHERE resource_type = ? AND resource_id = ? AND metric_type = ?
|
||||
AND tier = ? AND timestamp < ?
|
||||
AND tier = ? AND timestamp >= ? AND timestamp < ?
|
||||
GROUP BY resource_type, resource_id, metric_type, bucket_ts
|
||||
`, bucketSecs, bucketSecs, string(toTier), resourceType, resourceID, metricType, string(fromTier), cutoff)
|
||||
`, bucketSecs, bucketSecs, string(toTier), resourceType, resourceID, metricType, string(fromTier), startTs, endTs)
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).
|
||||
|
|
@ -601,21 +629,48 @@ func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fro
|
|||
return
|
||||
}
|
||||
|
||||
// Delete rolled-up raw data
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM metrics
|
||||
WHERE resource_type = ? AND resource_id = ? AND metric_type = ?
|
||||
AND tier = ? AND timestamp < ?
|
||||
`, resourceType, resourceID, metricType, string(fromTier), cutoff)
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to delete rolled-up metrics")
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) getMetaInt(key string) (int64, bool) {
|
||||
var value string
|
||||
err := s.db.QueryRow(`SELECT value FROM metrics_meta WHERE key = ?`, key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("key", key).Msg("Failed to read metrics metadata")
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("key", key).Msg("Invalid metrics metadata value")
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func (s *Store) setMetaInt(key string, value int64) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO metrics_meta (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`, key, strconv.FormatInt(value, 10))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) getMaxTimestampForTier(tier Tier) (int64, bool) {
|
||||
var maxTs sql.NullInt64
|
||||
if err := s.db.QueryRow(`SELECT MAX(timestamp) FROM metrics WHERE tier = ?`, string(tier)).Scan(&maxTs); err != nil {
|
||||
log.Warn().Err(err).Str("tier", string(tier)).Msg("Failed to read metrics max timestamp")
|
||||
return 0, false
|
||||
}
|
||||
if !maxTs.Valid || maxTs.Int64 <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return maxTs.Int64, true
|
||||
}
|
||||
|
||||
// runRetention deletes data older than retention period
|
||||
func (s *Store) runRetention() {
|
||||
start := time.Now()
|
||||
|
|
@ -675,6 +730,16 @@ func (s *Store) Close() error {
|
|||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Clear removes all stored metrics data.
|
||||
func (s *Store) Clear() error {
|
||||
s.Flush()
|
||||
if _, err := s.db.Exec("DELETE FROM metrics"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.db.Exec("DELETE FROM metrics_meta")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats holds metrics store statistics
|
||||
type Stats struct {
|
||||
DBSize int64 `json:"dbSize"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue