mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-21 18:46:08 +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'}>
|
<Show when={activeTab() === 'history'}>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Toolbar: Range and View Toggle */}
|
{/* 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 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 justify-between">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Controls</span>
|
<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-lg p-0.5">
|
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('unified')}
|
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'
|
? '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'
|
: '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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('split')}
|
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'
|
? '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'
|
: '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>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700/30">
|
<div class="flex items-center gap-2 sm:ml-auto">
|
||||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Range</span>
|
<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-lg p-0.5">
|
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
|
||||||
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
|
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setHistoryRange(r)}
|
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'
|
? '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'
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ interface RingBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration
|
// 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 SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
|
||||||
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~2880 points
|
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~2880 points
|
||||||
const STORAGE_KEY = 'pulse_metrics_history';
|
const STORAGE_KEY = 'pulse_metrics_history';
|
||||||
|
|
|
||||||
|
|
@ -651,7 +651,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
||||||
}
|
}
|
||||||
|
|
||||||
diag := &APITokenDiagnostic{
|
diag := &APITokenDiagnostic{
|
||||||
Enabled: cfg.APITokenEnabled,
|
Enabled: cfg.HasAPITokens(),
|
||||||
TokenCount: len(cfg.APITokens),
|
TokenCount: len(cfg.APITokens),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -682,9 +682,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
||||||
diag.RecommendTokenSetup = len(cfg.APITokens) == 0
|
diag.RecommendTokenSetup = len(cfg.APITokens) == 0
|
||||||
diag.RecommendTokenRotation = envTokens || legacyToken
|
diag.RecommendTokenRotation = envTokens || legacyToken
|
||||||
|
|
||||||
if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 {
|
if diag.RecommendTokenSetup {
|
||||||
appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.")
|
|
||||||
} else if diag.RecommendTokenSetup {
|
|
||||||
appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.")
|
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 {
|
type Config struct {
|
||||||
// Server settings
|
// Server settings
|
||||||
BackendPort int
|
BackendPort int
|
||||||
|
BackendHost string
|
||||||
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
|
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
DataPath string
|
DataPath string
|
||||||
|
|
@ -556,6 +557,7 @@ func Load() (*Config, error) {
|
||||||
// Initialize config with defaults
|
// Initialize config with defaults
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
BackendPort: 3000,
|
BackendPort: 3000,
|
||||||
|
BackendHost: "0.0.0.0",
|
||||||
FrontendPort: 7655,
|
FrontendPort: 7655,
|
||||||
ConfigPath: dataDir,
|
ConfigPath: dataDir,
|
||||||
DataPath: dataDir,
|
DataPath: dataDir,
|
||||||
|
|
@ -1011,6 +1013,11 @@ func Load() (*Config, error) {
|
||||||
cfg.EnvOverrides["ALLOWED_ORIGINS"] = true
|
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 sshPort := utils.GetenvTrim("SSH_PORT"); sshPort != "" {
|
||||||
if port, err := strconv.Atoi(sshPort); err == nil {
|
if port, err := strconv.Atoi(sshPort); err == nil {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,11 @@ func (m *Monitor) startMockMetricsSampler(ctx context.Context) {
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
state := mock.GetMockState()
|
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)
|
seedMockMetricsHistory(m.metricsHistory, m.metricsStore, state, time.Now(), seedDuration, cfg.SampleInterval)
|
||||||
recordMockStateToMetricsHistory(m.metricsHistory, m.metricsStore, state, time.Now())
|
recordMockStateToMetricsHistory(m.metricsHistory, m.metricsStore, state, time.Now())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSeedMockMetricsHistory_PopulatesSeries(t *testing.T) {
|
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)
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -526,13 +527,33 @@ func (s *Store) runRollup() {
|
||||||
func (s *Store) rollupTier(fromTier, toTier Tier, bucketSize, minAge time.Duration) {
|
func (s *Store) rollupTier(fromTier, toTier Tier, bucketSize, minAge time.Duration) {
|
||||||
cutoff := time.Now().Add(-minAge).Unix()
|
cutoff := time.Now().Add(-minAge).Unix()
|
||||||
bucketSecs := int64(bucketSize.Seconds())
|
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
|
// Find distinct resource/metric combinations that need rollup
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT DISTINCT resource_type, resource_id, metric_type
|
SELECT DISTINCT resource_type, resource_id, metric_type
|
||||||
FROM metrics
|
FROM metrics
|
||||||
WHERE tier = ? AND timestamp < ?
|
WHERE tier = ? AND timestamp >= ? AND timestamp < ?
|
||||||
`, string(fromTier), cutoff)
|
`, string(fromTier), lastBucket, cutoffBucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("tier", string(fromTier)).Msg("Failed to find rollup candidates")
|
log.Error().Err(err).Str("tier", string(fromTier)).Msg("Failed to find rollup candidates")
|
||||||
return
|
return
|
||||||
|
|
@ -562,12 +583,19 @@ func (s *Store) rollupTier(fromTier, toTier Tier, bucketSize, minAge time.Durati
|
||||||
|
|
||||||
// Process each candidate
|
// Process each candidate
|
||||||
for _, c := range candidates {
|
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
|
// 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()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -588,9 +616,9 @@ func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fro
|
||||||
?
|
?
|
||||||
FROM metrics
|
FROM metrics
|
||||||
WHERE resource_type = ? AND resource_id = ? AND metric_type = ?
|
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
|
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 {
|
if err != nil {
|
||||||
log.Warn().Err(err).
|
log.Warn().Err(err).
|
||||||
|
|
@ -601,21 +629,48 @@ func (s *Store) rollupCandidate(resourceType, resourceID, metricType string, fro
|
||||||
return
|
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()
|
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
|
// runRetention deletes data older than retention period
|
||||||
func (s *Store) runRetention() {
|
func (s *Store) runRetention() {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
@ -675,6 +730,16 @@ func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
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
|
// Stats holds metrics store statistics
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
DBSize int64 `json:"dbSize"`
|
DBSize int64 `json:"dbSize"`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue