Combine enrichment pills into unified rate monitor cards, debounce idle
- Merge enrichment worker status into rate monitor WebSocket payload - Hide old enrichment pills — rate monitor cards now show: service name, worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar - Debounce idle detection with 5s grace period — prevents status flickering between Running and Idle on every worker cycle - Responsive grid layout with richer card design
This commit is contained in:
parent
93bde811ed
commit
d5bb0dfaa1
4 changed files with 287 additions and 71 deletions
|
|
@ -4009,6 +4009,9 @@ def _get_windowed_calls(key, current_total):
|
|||
return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total)
|
||||
|
||||
|
||||
_idle_since = {} # worker_key -> timestamp when current_item first became None
|
||||
_IDLE_GRACE_SECONDS = 5 # Don't report idle until current_item has been None this long
|
||||
|
||||
def _get_enrichment_status():
|
||||
"""Get lightweight status for all enrichment services (no DB queries).
|
||||
Reads worker properties directly to avoid expensive get_stats() calls."""
|
||||
|
|
@ -4050,12 +4053,22 @@ def _get_enrichment_status():
|
|||
total_processed = stats.get('matched', 0) + stats.get('not_found', 0) + stats.get('errors', 0)
|
||||
calls_1h, calls_24h = _get_windowed_calls(key, total_processed)
|
||||
|
||||
# Debounced idle detection — only report idle after 5s of no current_item
|
||||
has_item = getattr(worker, 'current_item', None) is not None
|
||||
if has_item:
|
||||
_idle_since.pop(key, None)
|
||||
is_idle = False
|
||||
else:
|
||||
if key not in _idle_since:
|
||||
_idle_since[key] = time.time()
|
||||
is_idle = (time.time() - _idle_since[key]) >= _IDLE_GRACE_SECONDS
|
||||
|
||||
svc_data = {
|
||||
'name': name,
|
||||
'configured': configured,
|
||||
'running': worker.running and is_alive and not worker.paused,
|
||||
'paused': worker.paused,
|
||||
'idle': is_alive and not worker.paused and getattr(worker, 'current_item', None) is None,
|
||||
'idle': is_alive and not worker.paused and is_idle,
|
||||
'calls_1h': calls_1h,
|
||||
'calls_24h': calls_24h,
|
||||
}
|
||||
|
|
@ -48300,13 +48313,43 @@ _download_yield_override = set() # Workers the user explicitly resumed during d
|
|||
|
||||
|
||||
def _emit_rate_monitor_loop():
|
||||
"""Background thread that pushes API call rate data every 1 second for speedometer gauges."""
|
||||
"""Background thread that pushes API call rate data every 1 second for speedometer gauges.
|
||||
Also includes enrichment worker status so the combined cards have everything."""
|
||||
# Map rate monitor service keys to enrichment status keys
|
||||
_enrichment_key_map = {
|
||||
'spotify': 'spotify_enrichment', 'itunes': 'itunes_enrichment',
|
||||
'deezer': 'deezer_enrichment', 'lastfm': 'lastfm', 'genius': 'genius',
|
||||
'musicbrainz': 'musicbrainz', 'audiodb': 'audiodb',
|
||||
'tidal': 'tidal_enrichment', 'qobuz': 'qobuz_enrichment',
|
||||
}
|
||||
|
||||
while True:
|
||||
socketio.sleep(1)
|
||||
try:
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
payload = api_call_tracker.get_all_rates()
|
||||
|
||||
# Merge enrichment worker status into each service
|
||||
try:
|
||||
enrichment = _get_enrichment_status()
|
||||
for svc_key, entry in payload.items():
|
||||
enr_key = _enrichment_key_map.get(svc_key)
|
||||
enr = enrichment.get(enr_key) if enr_key else None
|
||||
if enr:
|
||||
entry['worker'] = {
|
||||
'status': 'not_configured' if not enr.get('configured') else
|
||||
'paused' if enr.get('paused') else
|
||||
'idle' if enr.get('idle') else
|
||||
'running' if enr.get('running') else 'stopped',
|
||||
'yield_reason': enr.get('yield_reason', ''),
|
||||
'calls_1h': enr.get('calls_1h', 0),
|
||||
'calls_24h': enr.get('calls_24h', 0),
|
||||
}
|
||||
if svc_key == 'spotify' and enr.get('daily_budget'):
|
||||
entry['worker']['daily_budget'] = enr['daily_budget']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add Spotify rate limit state
|
||||
try:
|
||||
if spotify_client:
|
||||
|
|
|
|||
|
|
@ -669,19 +669,19 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enrichment-section">
|
||||
<div class="enrichment-section" id="enrichment-pills-section" style="display:none">
|
||||
<div class="enrichment-section-header">
|
||||
<span class="enrichment-section-label">Enrichment Services</span>
|
||||
</div>
|
||||
<div class="enrichment-status-grid" id="enrichment-status-grid">
|
||||
<!-- Dynamically populated by JS -->
|
||||
<!-- Legacy pills — hidden when rate monitor active -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Rate Monitor Section -->
|
||||
<!-- API Rate Monitor Section (replaces enrichment pills with richer cards) -->
|
||||
<div class="dashboard-section" id="rate-monitor-section">
|
||||
<h3 class="section-title">API Rate Monitor</h3>
|
||||
<h3 class="section-title">Enrichment Services</h3>
|
||||
<div class="rate-monitor-grid" id="rate-monitor-grid">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37685,8 +37685,8 @@ const _RATE_GAUGE_COLORS = {
|
|||
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35',
|
||||
};
|
||||
|
||||
// SVG constants — 240° arc, gap at bottom, extra padding for glow
|
||||
const _G = { size: 220, cx: 110, cy: 116, r: 74, stroke: 10, startAngle: 240, totalArc: 240 };
|
||||
// SVG constants — 240° arc, gap at bottom
|
||||
const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 };
|
||||
|
||||
function _gPt(angle, radius) {
|
||||
const rad = (angle - 90) * Math.PI / 180;
|
||||
|
|
@ -37726,36 +37726,112 @@ function _handleRateMonitorUpdate(data) {
|
|||
const value = d.cpm || 0;
|
||||
const max = d.limit || 60;
|
||||
const pct = Math.min(value / max, 1);
|
||||
|
||||
let svg = container.querySelector('svg');
|
||||
if (!svg) {
|
||||
container.innerHTML = _buildGaugeSVG(svc, value, max);
|
||||
} else {
|
||||
_updateGauge(container, value, max, svc);
|
||||
}
|
||||
|
||||
container.classList.toggle('danger', pct > 0.8);
|
||||
container.classList.toggle('active', value > 0);
|
||||
|
||||
// Rate limited badge (Spotify only for now)
|
||||
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
|
||||
const label = _RATE_GAUGE_LABELS[svc] || svc;
|
||||
const worker = d.worker || {};
|
||||
const wStatus = worker.status || 'stopped';
|
||||
const isRateLimited = d.rate_limited === true;
|
||||
container.classList.toggle('rate-limited', isRateLimited);
|
||||
let badge = container.querySelector('.gauge-rl-badge');
|
||||
if (isRateLimited) {
|
||||
const mins = Math.ceil((d.rl_remaining || 0) / 60);
|
||||
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
||||
if (!badge) {
|
||||
badge = document.createElement('div');
|
||||
badge.className = 'gauge-rl-badge';
|
||||
container.appendChild(badge);
|
||||
|
||||
// Build or update the card content
|
||||
let gaugeWrap = container.querySelector('.gauge-arc-wrap');
|
||||
if (!gaugeWrap) {
|
||||
// Full rebuild
|
||||
container.innerHTML = `
|
||||
<div class="gauge-card-header">
|
||||
<span class="gauge-card-dot" style="background:${accent}"></span>
|
||||
<span class="gauge-card-name">${label}</span>
|
||||
<span class="gauge-card-status" data-status="${wStatus}">${_workerStatusLabel(wStatus, worker)}</span>
|
||||
</div>
|
||||
<div class="gauge-arc-wrap">${_buildGaugeSVG(svc, value, max)}</div>
|
||||
<div class="gauge-card-stats">
|
||||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${value.toFixed(0)}</span><span class="gauge-card-stat-label">calls/min</span></div>
|
||||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${worker.calls_1h || 0}</span><span class="gauge-card-stat-label">last hour</span></div>
|
||||
<div class="gauge-card-stat"><span class="gauge-card-stat-val">${(worker.calls_24h || 0).toLocaleString()}</span><span class="gauge-card-stat-label">24h</span></div>
|
||||
</div>
|
||||
${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''}
|
||||
${isRateLimited ? _buildRateLimitBadge(d) : ''}
|
||||
`;
|
||||
} else {
|
||||
// Fast update — only change values
|
||||
_updateGauge(gaugeWrap, value, max, svc);
|
||||
|
||||
// Update status
|
||||
const statusEl = container.querySelector('.gauge-card-status');
|
||||
if (statusEl) {
|
||||
statusEl.dataset.status = wStatus;
|
||||
statusEl.textContent = _workerStatusLabel(wStatus, worker);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const statVals = container.querySelectorAll('.gauge-card-stat-val');
|
||||
if (statVals[0]) statVals[0].textContent = value.toFixed(0);
|
||||
if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0;
|
||||
if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString();
|
||||
|
||||
// Budget bar (Spotify)
|
||||
if (svc === 'spotify' && worker.daily_budget) {
|
||||
let budgetEl = container.querySelector('.gauge-budget-bar');
|
||||
if (!budgetEl) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = _buildBudgetBar(worker.daily_budget);
|
||||
const statsEl = container.querySelector('.gauge-card-stats');
|
||||
if (statsEl) statsEl.after(div.firstElementChild);
|
||||
} else {
|
||||
const b = worker.daily_budget;
|
||||
const pctB = Math.min(100, Math.round((b.used / b.limit) * 100));
|
||||
const fill = budgetEl.querySelector('.gauge-budget-fill');
|
||||
if (fill) { fill.style.width = pctB + '%'; }
|
||||
const label = budgetEl.querySelector('.gauge-budget-label');
|
||||
if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit badge
|
||||
let badge = container.querySelector('.gauge-rl-badge');
|
||||
if (isRateLimited) {
|
||||
if (!badge) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = _buildRateLimitBadge(d);
|
||||
container.appendChild(div.firstElementChild);
|
||||
} else {
|
||||
const mins = Math.ceil((d.rl_remaining || 0) / 60);
|
||||
const timeEl = badge.querySelector('.gauge-rl-time');
|
||||
if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
badge.innerHTML = `<span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span>`;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
|
||||
container.classList.toggle('danger', pct > 0.8 || isRateLimited);
|
||||
container.classList.toggle('active', value > 0 || wStatus === 'running');
|
||||
container.classList.toggle('rate-limited', isRateLimited);
|
||||
}
|
||||
}
|
||||
|
||||
function _workerStatusLabel(status, worker) {
|
||||
if (status === 'not_configured') return 'Not configured';
|
||||
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
|
||||
if (status === 'idle') return 'Idle';
|
||||
if (status === 'running') return 'Running';
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
function _buildBudgetBar(budget) {
|
||||
const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100));
|
||||
const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : '';
|
||||
return `<div class="gauge-budget-bar ${cls}">
|
||||
<div class="gauge-budget-fill" style="width:${pct}%"></div>
|
||||
<span class="gauge-budget-label">${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _buildRateLimitBadge(d) {
|
||||
const mins = Math.ceil((d.rl_remaining || 0) / 60);
|
||||
const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
||||
return `<div class="gauge-rl-badge"><span class="gauge-rl-dot"></span>RATE LIMITED<span class="gauge-rl-time">${text}</span></div>`;
|
||||
}
|
||||
|
||||
function _buildGaugeSVG(svc, value, max) {
|
||||
const { size, cx, cy, r, stroke, startAngle, totalArc } = _G;
|
||||
const label = _RATE_GAUGE_LABELS[svc] || svc;
|
||||
|
|
|
|||
|
|
@ -8195,45 +8195,44 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
|
||||
.rate-monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.rate-gauge-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 4px 4px;
|
||||
border-radius: 16px;
|
||||
background: rgba(12, 12, 16, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
flex-direction: column;
|
||||
padding: 14px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rate-gauge-card:hover {
|
||||
background: rgba(20, 20, 28, 0.8);
|
||||
background: rgba(24, 24, 30, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.rate-gauge-card.active {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.rate-gauge-card.danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gauge-danger-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0), inset 0 0 0 rgba(239, 68, 68, 0); }
|
||||
50% { box-shadow: 0 0 24px rgba(239, 68, 68, 0.15), inset 0 0 20px rgba(239, 68, 68, 0.03); }
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(239, 68, 68, 0); }
|
||||
50% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.12); }
|
||||
}
|
||||
|
||||
.rate-gauge-card.rate-limited {
|
||||
|
|
@ -8241,23 +8240,122 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
animation: gauge-danger-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gauge-rl-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
/* Card header — service name + status */
|
||||
.gauge-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gauge-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gauge-card-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.gauge-card-status {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.gauge-card-status[data-status="running"] { background: rgba(34, 197, 94, 0.12); color: #4ade80; }
|
||||
.gauge-card-status[data-status="idle"] { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.35); }
|
||||
.gauge-card-status[data-status="paused"] { background: rgba(234, 179, 8, 0.12); color: #fbbf24; }
|
||||
.gauge-card-status[data-status="stopped"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
|
||||
.gauge-card-status[data-status="not_configured"] { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.2); }
|
||||
|
||||
/* Arc wrap */
|
||||
.gauge-arc-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: -4px 0;
|
||||
}
|
||||
|
||||
/* Stats row below gauge */
|
||||
.gauge-card-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.gauge-card-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.gauge-card-stat-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.gauge-card-stat-label {
|
||||
font-size: 8px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Spotify budget bar */
|
||||
.gauge-budget-bar {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
margin: 6px 4px 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gauge-budget-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, rgba(29, 185, 84, 0.7), #1DB954);
|
||||
transition: width 0.8s ease;
|
||||
}
|
||||
|
||||
.gauge-budget-bar.high .gauge-budget-fill { background: linear-gradient(90deg, rgba(245, 166, 35, 0.7), #f5a623); }
|
||||
.gauge-budget-bar.exhausted .gauge-budget-fill { background: linear-gradient(90deg, rgba(239, 68, 68, 0.7), #ef4444); }
|
||||
|
||||
.gauge-budget-label {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Rate limit badge */
|
||||
.gauge-rl-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
padding: 4px 10px;
|
||||
margin-top: 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gauge-rl-dot {
|
||||
|
|
@ -8274,12 +8372,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
.gauge-rl-time {
|
||||
color: rgba(239, 68, 68, 0.6);
|
||||
color: rgba(239, 68, 68, 0.5);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* SVG gauge */
|
||||
.rate-gauge-svg {
|
||||
width: 100%;
|
||||
max-width: 160px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
|
@ -8298,24 +8398,20 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
|
||||
.gauge-value {
|
||||
font-family: -apple-system, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
letter-spacing: -1px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.gauge-unit {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
fill: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
fill: rgba(255, 255, 255, 0.45);
|
||||
letter-spacing: 0.5px;
|
||||
display: none; /* Label is now in the card header, not the SVG */
|
||||
}
|
||||
|
||||
/* Rate Monitor Detail Modal */
|
||||
|
|
@ -8470,18 +8566,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 900px) {
|
||||
.rate-monitor-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.rate-gauge-card { padding: 10px 8px 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 600px) {
|
||||
.rate-monitor-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.gauge-card-stats { gap: 2px; }
|
||||
.gauge-card-stat-val { font-size: 11px; }
|
||||
}
|
||||
|
||||
/* System Stats Grid */
|
||||
|
|
|
|||
Loading…
Reference in a new issue