Fix mock metrics history and guest drawer controls

This commit is contained in:
rcourtman 2026-01-22 09:39:53 +00:00
parent 6e2cae2363
commit c75972d57c
7 changed files with 158 additions and 33 deletions

View file

@ -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'
}`}

View file

@ -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';

View file

@ -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.")
}

View file

@ -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 {

View file

@ -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())

View file

@ -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")
}
}

View file

@ -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"`