feat(ui): Add progressive freshness indicators to dashboards

Add visible "Updated Xs ago" timestamps and freshness indicators to
make cached stats look more alive and help users know data currency.

Backend changes:
- luci.metrics: Add _freshness metadata (age, fresh, timestamp_epoch)
  to overview, waf_stats, and connections responses
- luci.crowdsec-dashboard: Add _freshness metadata to get_overview
  response using sed injection into cached JSON

Frontend changes:
- metrics/dashboard.js: Display freshness indicator (green/yellow/red)
  in header, animate value changes with flash effect
- crowdsec-dashboard/overview.js: Display freshness indicator next to
  running badge, update on poll

Shared utilities (kiss-theme.js):
- formatAge(seconds): Format "Xs ago", "Xm ago", "Xh ago"
- getFreshnessClass(age): Return fresh/recent/stale based on age
- getFreshnessColor(class): Return #00c853/#ff9800/#f44336
- freshnessIndicator(age, id): Create indicator DOM element
- updateFreshness(age, id): Update existing indicator

Freshness thresholds:
- Fresh (green): < 15s for metrics, < 30s for CrowdSec
- Recent (yellow): < 45s for metrics, < 90s for CrowdSec
- Stale (red): > threshold

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-20 11:05:01 +01:00
parent 355c050700
commit b99dabaca9
6 changed files with 294 additions and 25 deletions

View File

@ -1,6 +1,27 @@
# SecuBox UI & Theme History # SecuBox UI & Theme History
_Last updated: 2026-03-17 (VM Firmware Build + CI Fixes)_ _Last updated: 2026-03-20 (Seed Script + Package Repo)_
0. **SecuBox Seed Script & Package Repository (2026-03-20)**
- NEW: `scripts/secubox-seed.sh` - Bootstrap script for fresh OpenWrt
- NEW: `scripts/secubox-slipstream.sh` - Bake SecuBox config into images
- NEW: `.github/workflows/publish-package-repo.yml` - GitHub Pages package repo
- Package repository live at https://repo.secubox.in
- **Seed script features**:
- Auto-detects architecture (x86_64, aarch64_cortex-a72, etc.)
- Configures SecuBox opkg feeds in customfeeds.conf
- Disables signature checking for unsigned feeds (fixes "Unknown package" error)
- Retry logic (3 attempts) for all network operations
- Profile-based installation: minimal, standard, full
- Dry-run mode for testing
- **Slipstream script**: Pre-configures images during build
- Creates /etc/opkg/customfeeds.conf with SecuBox feeds
- First-boot script (/etc/uci-defaults/99-secubox-setup)
- Custom banner with SecuBox branding
- GitHub Actions integration:
- VM images (build-secubox-vm.yml) now include slipstream
- Device images (build-secubox-images.yml) now include slipstream
- Package repo deployed to GitHub Pages on release
0. **SecuBox VM Firmware Build Workflow (2026-03-17)** 0. **SecuBox VM Firmware Build Workflow (2026-03-17)**
- NEW: `.github/workflows/build-secubox-vm.yml` for x86_64 VM appliance images - NEW: `.github/workflows/build-secubox-vm.yml` for x86_64 VM appliance images

View File

@ -6,6 +6,19 @@
'require crowdsec-dashboard.heatmap as heatmap'; 'require crowdsec-dashboard.heatmap as heatmap';
'require secubox/kiss-theme'; 'require secubox/kiss-theme';
// Freshness helpers
function formatAge(seconds) {
if (seconds < 60) return seconds + 's ago';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
return Math.floor(seconds / 3600) + 'h ago';
}
function getFreshnessClass(age) {
if (age < 30) return 'fresh'; // < 30s = green/fresh
if (age < 90) return 'recent'; // < 90s = yellow/recent
return 'stale'; // > 90s = red/stale
}
return view.extend({ return view.extend({
load: function() { load: function() {
var link = document.createElement('link'); var link = document.createElement('link');
@ -103,14 +116,25 @@ return view.extend({
s.geoLocal = this.parseGeoData(s.geo_local_raw || s.top_countries_raw); s.geoLocal = this.parseGeoData(s.geo_local_raw || s.top_countries_raw);
s.geoCapi = this.parseGeoData(s.geo_capi_raw); s.geoCapi = this.parseGeoData(s.geo_capi_raw);
// Parse freshness data
var freshness = s._freshness || { age: 0, fresh: true };
var freshClass = getFreshnessClass(freshness.age || 0);
var freshAge = freshness.age || 0;
var content = [ var content = [
// Header // Header with freshness indicator
E('div', { 'style': 'margin-bottom: 24px;' }, [ E('div', { 'style': 'margin-bottom: 24px;' }, [
E('div', { 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [
E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [ E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [
E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'), E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'),
KissTheme.badge(s.crowdsec === 'running' ? 'RUNNING' : 'STOPPED', KissTheme.badge(s.crowdsec === 'running' ? 'RUNNING' : 'STOPPED',
s.crowdsec === 'running' ? 'green' : 'red') s.crowdsec === 'running' ? 'green' : 'red')
]), ]),
E('div', { 'class': 'cs-freshness', 'id': 'cs-freshness', 'style': 'display:flex; align-items:center; gap:8px; padding:6px 12px; border-radius:16px; background:var(--kiss-bg2); border:1px solid var(--kiss-line); font-size:12px;' }, [
E('span', { 'class': 'cs-fresh-dot ' + freshClass, 'id': 'cs-fresh-dot', 'style': 'width:8px; height:8px; border-radius:50%; background:' + (freshClass === 'fresh' ? '#00c853' : (freshClass === 'recent' ? '#ff9800' : '#f44336')) + '; box-shadow:0 0 4px ' + (freshClass === 'fresh' ? '#00c853' : (freshClass === 'recent' ? '#ff9800' : '#f44336')) + ';' }),
E('span', { 'id': 'cs-age', 'style': 'color:' + (freshClass === 'fresh' ? '#00c853' : (freshClass === 'recent' ? '#ff9800' : '#f44336')) + ';' }, freshAge < 5 ? 'just now' : formatAge(freshAge))
])
]),
E('p', { 'style': 'color: var(--kiss-muted); margin: 8px 0 0 0;' }, 'Collaborative security engine') E('p', { 'style': 'color: var(--kiss-muted); margin: 8px 0 0 0;' }, 'Collaborative security engine')
]), ]),
@ -262,6 +286,23 @@ return view.extend({
s.alerts = self.parseAlerts(s); s.alerts = self.parseAlerts(s);
s.geoLocal = self.parseGeoData(s.geo_local_raw || s.top_countries_raw); s.geoLocal = self.parseGeoData(s.geo_local_raw || s.top_countries_raw);
s.geoCapi = self.parseGeoData(s.geo_capi_raw); s.geoCapi = self.parseGeoData(s.geo_capi_raw);
// Update freshness indicator
var freshness = s._freshness || { age: 0, fresh: true };
var freshClass = getFreshnessClass(freshness.age || 0);
var freshAge = freshness.age || 0;
var dotEl = document.getElementById('cs-fresh-dot');
var ageEl = document.getElementById('cs-age');
if (dotEl) {
var color = freshClass === 'fresh' ? '#00c853' : (freshClass === 'recent' ? '#ff9800' : '#f44336');
dotEl.style.background = color;
dotEl.style.boxShadow = '0 0 4px ' + color;
}
if (ageEl) {
ageEl.textContent = freshAge < 5 ? 'just now' : formatAge(freshAge);
ageEl.style.color = freshClass === 'fresh' ? '#00c853' : (freshClass === 'recent' ? '#ff9800' : '#f44336');
}
var el = document.getElementById('cs-stats'); var el = document.getElementById('cs-stats');
if (el) dom.content(el, self.renderStats(s)); if (el) dom.content(el, self.renderStats(s));
el = document.getElementById('cs-alerts'); el = document.getElementById('cs-alerts');

View File

@ -2266,15 +2266,27 @@ save_settings() {
# get_overview - returns cached data for instant response # get_overview - returns cached data for instant response
# Cache is refreshed by background cron job or refresh_overview_cache call # Cache is refreshed by background cron job or refresh_overview_cache call
get_overview() { get_overview() {
# Calculate cache age for freshness indicator
local cache_age=999
local cache_fresh=false
if [ -f "$CROWDSEC_OVERVIEW_CACHE" ]; then
local now=$(date +%s)
local mtime=$(stat -c %Y "$CROWDSEC_OVERVIEW_CACHE" 2>/dev/null || echo 0)
cache_age=$((now - mtime))
[ "$cache_age" -lt "$CACHE_MAX_AGE" ] && cache_fresh=true
fi
# Return cached data if available and fresh # Return cached data if available and fresh
if is_cache_fresh "$CROWDSEC_OVERVIEW_CACHE"; then if is_cache_fresh "$CROWDSEC_OVERVIEW_CACHE"; then
cat "$CROWDSEC_OVERVIEW_CACHE" # Inject freshness metadata into cached response
sed "s/}$/,\"_freshness\":{\"age\":$cache_age,\"fresh\":true}}/" "$CROWDSEC_OVERVIEW_CACHE"
return 0 return 0
fi fi
# If cache exists but stale, return stale data (better than timeout) # If cache exists but stale, return stale data (better than timeout)
if [ -f "$CROWDSEC_OVERVIEW_CACHE" ]; then if [ -f "$CROWDSEC_OVERVIEW_CACHE" ]; then
cat "$CROWDSEC_OVERVIEW_CACHE" # Inject freshness metadata
sed "s/}$/,\"_freshness\":{\"age\":$cache_age,\"fresh\":false}}/" "$CROWDSEC_OVERVIEW_CACHE"
# Trigger async refresh # Trigger async refresh
( refresh_overview_cache ) >/dev/null 2>&1 & ( refresh_overview_cache ) >/dev/null 2>&1 &
return 0 return 0

View File

@ -35,6 +35,21 @@ function formatMem(kb) {
return (kb / 1048576).toFixed(1) + ' GB'; return (kb / 1048576).toFixed(1) + ' GB';
} }
function formatAge(seconds) {
if (seconds < 60) return seconds + 's ago';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
return Math.floor(seconds / 3600) + 'h ago';
}
function getFreshnessClass(age) {
if (age < 15) return 'fresh'; // < 15s = green/fresh
if (age < 45) return 'recent'; // < 45s = yellow/recent
return 'stale'; // > 45s = red/stale
}
// Track previous values for change detection
var prevValues = {};
return view.extend({ return view.extend({
load: function() { load: function() {
// Inject custom CSS // Inject custom CSS
@ -70,6 +85,54 @@ return view.extend({
@keyframes blink { @keyframes blink {
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes flash {
0% { background: rgba(0, 200, 83, 0.3); }
100% { background: transparent; }
}
.mx-changed {
animation: flash 0.5s ease-out;
}
/* Freshness indicators */
.mx-freshness {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 10px;
border-radius: 12px;
background: var(--kiss-bg2, #1a1a2e);
border: 1px solid var(--kiss-border, #2a2a40);
}
.mx-freshness-dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background 0.3s, box-shadow 0.3s;
}
.mx-freshness-dot.fresh {
background: #00c853;
box-shadow: 0 0 6px #00c853;
}
.mx-freshness-dot.recent {
background: #ff9800;
box-shadow: 0 0 4px #ff9800;
}
.mx-freshness-dot.stale {
background: #f44336;
box-shadow: 0 0 4px #f44336;
}
.mx-age {
color: var(--kiss-muted, #888);
transition: color 0.3s;
}
.mx-age.fresh { color: #00c853; }
.mx-age.recent { color: #ff9800; }
.mx-age.stale { color: #f44336; }
/* Stats Grid */ /* Stats Grid */
.mx-grid { .mx-grid {
@ -211,14 +274,19 @@ return view.extend({
var memCls = memPct > 85 ? 'red' : (memPct > 70 ? 'orange' : 'green'); var memCls = memPct > 85 ? 'red' : (memPct > 70 ? 'orange' : 'green');
var content = [ var content = [
// Header // Header with freshness indicator
E('div', { 'class': 'mx-header' }, [ E('div', { 'class': 'mx-header' }, [
E('div', { 'class': 'mx-title' }, 'Metrics Dashboard'), E('div', { 'class': 'mx-title' }, 'Metrics Dashboard'),
E('div', { 'style': 'display:flex; align-items:center; gap:16px;' }, [
E('div', { 'class': 'mx-freshness', 'id': 'mx-freshness' }, [
E('span', { 'class': 'mx-freshness-dot fresh', 'id': 'mx-fresh-dot' }),
E('span', { 'class': 'mx-age fresh', 'id': 'mx-age' }, 'just now')
]),
E('div', { 'class': 'mx-live' }, [ E('div', { 'class': 'mx-live' }, [
E('span', { 'class': 'mx-dot' }), E('span', { 'class': 'mx-dot' }),
'LIVE',
E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString()) E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString())
]) ])
])
]), ]),
// Main stats grid // Main stats grid
@ -354,6 +422,22 @@ return view.extend({
var w = data[1] || {}; var w = data[1] || {};
var c = data[2] || {}; var c = data[2] || {};
// Extract freshness info (from overview response)
var freshness = o._freshness || { age: 0, fresh: true };
var age = freshness.age || 0;
var freshClass = getFreshnessClass(age);
// Update freshness indicator
var freshDot = document.getElementById('mx-fresh-dot');
var ageEl = document.getElementById('mx-age');
if (freshDot) {
freshDot.className = 'mx-freshness-dot ' + freshClass;
}
if (ageEl) {
ageEl.textContent = age < 5 ? 'just now' : formatAge(age);
ageEl.className = 'mx-age ' + freshClass;
}
var upd = { var upd = {
's-up': formatUptime(o.uptime || 0), 's-up': formatUptime(o.uptime || 0),
's-load': 'Load: ' + (o.load || '0'), 's-load': 'Load: ' + (o.load || '0'),
@ -372,7 +456,17 @@ return view.extend({
for (var id in upd) { for (var id in upd) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.textContent = upd[id]; if (el) {
var newVal = upd[id];
// Animate if value changed
if (prevValues[id] !== undefined && prevValues[id] !== newVal) {
el.classList.remove('mx-changed');
void el.offsetWidth; // Force reflow
el.classList.add('mx-changed');
}
el.textContent = newVal;
prevValues[id] = newVal;
}
} }
// Service dots // Service dots

View File

@ -20,6 +20,14 @@ cache_is_fresh() {
[ "$age" -lt "$CACHE_TTL" ] [ "$age" -lt "$CACHE_TTL" ]
} }
# Get cache age in seconds
get_cache_age() {
[ -f "$CACHE_FILE" ] || echo 999
local now=$(date +%s)
local mtime=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)
echo $((now - mtime))
}
# Build overview (called by cron or on stale cache) # Build overview (called by cron or on stale cache)
build_overview() { build_overview() {
local uptime load mem_total mem_free mem_used mem_pct local uptime load mem_total mem_free mem_used mem_pct
@ -88,14 +96,15 @@ build_connections() {
# Build full cache # Build full cache
build_cache() { build_cache() {
local overview waf conns ts local overview waf conns ts ts_epoch
overview=$(build_overview) overview=$(build_overview)
waf=$(build_waf_stats) waf=$(build_waf_stats)
conns=$(build_connections) conns=$(build_connections)
ts=$(date -Iseconds) ts=$(date -Iseconds)
ts_epoch=$(date +%s)
printf '{"overview":%s,"waf":%s,"connections":%s,"timestamp":"%s"}' \ printf '{"overview":%s,"waf":%s,"connections":%s,"timestamp":"%s","timestamp_epoch":%d}' \
"$overview" "$waf" "$conns" "$ts" > "$CACHE_FILE" "$overview" "$waf" "$conns" "$ts" "$ts_epoch" > "$CACHE_FILE"
} }
# Refresh cache in background if stale # Refresh cache in background if stale
@ -117,29 +126,61 @@ get_cached() {
fi fi
} }
# Fast getters from cache # Get freshness metadata
get_overview() { get_freshness() {
local age ts ts_epoch
if [ -f "$CACHE_FILE" ]; then if [ -f "$CACHE_FILE" ]; then
jsonfilter -i "$CACHE_FILE" -e '@.overview' 2>/dev/null || build_overview age=$(get_cache_age)
ts=$(jsonfilter -i "$CACHE_FILE" -e '@.timestamp' 2>/dev/null || echo "")
ts_epoch=$(jsonfilter -i "$CACHE_FILE" -e '@.timestamp_epoch' 2>/dev/null || echo 0)
else else
build_overview age=999
ts=""
ts_epoch=0
fi fi
printf '{"age":%d,"timestamp":"%s","timestamp_epoch":%d,"fresh":%s}' \
"$age" "$ts" "$ts_epoch" "$([ "$age" -lt "$CACHE_TTL" ] && echo true || echo false)"
}
# Fast getters from cache - now with freshness metadata
get_overview() {
local data freshness
if [ -f "$CACHE_FILE" ]; then
data=$(jsonfilter -i "$CACHE_FILE" -e '@.overview' 2>/dev/null)
[ -z "$data" ] && data=$(build_overview)
else
data=$(build_overview)
fi
freshness=$(get_freshness)
# Merge data with freshness
printf '%s' "$data" | sed 's/}$//'
printf ',"_freshness":%s}' "$freshness"
} }
get_waf_stats() { get_waf_stats() {
local data freshness
if [ -f "$CACHE_FILE" ]; then if [ -f "$CACHE_FILE" ]; then
jsonfilter -i "$CACHE_FILE" -e '@.waf' 2>/dev/null || build_waf_stats data=$(jsonfilter -i "$CACHE_FILE" -e '@.waf' 2>/dev/null)
[ -z "$data" ] && data=$(build_waf_stats)
else else
build_waf_stats data=$(build_waf_stats)
fi fi
freshness=$(get_freshness)
printf '%s' "$data" | sed 's/}$//'
printf ',"_freshness":%s}' "$freshness"
} }
get_connections() { get_connections() {
local data freshness
if [ -f "$CACHE_FILE" ]; then if [ -f "$CACHE_FILE" ]; then
jsonfilter -i "$CACHE_FILE" -e '@.connections' 2>/dev/null || build_connections data=$(jsonfilter -i "$CACHE_FILE" -e '@.connections' 2>/dev/null)
[ -z "$data" ] && data=$(build_connections)
else else
build_connections data=$(build_connections)
fi fi
freshness=$(get_freshness)
printf '%s' "$data" | sed 's/}$//'
printf ',"_freshness":%s}' "$freshness"
} }
# Simple getters (less critical, can compute) # Simple getters (less critical, can compute)

View File

@ -681,6 +681,66 @@ body.kiss-mode .cbi-section { max-width: 100% !important; width: 100% !important
return this.E('button', { 'class': 'kiss-btn' + (type ? ' kiss-btn-' + type : ''), 'onClick': onClick }, label); return this.E('button', { 'class': 'kiss-btn' + (type ? ' kiss-btn-' + type : ''), 'onClick': onClick }, label);
}, },
// Freshness utilities for cache timestamps
formatAge: function(seconds) {
if (seconds < 60) return seconds + 's ago';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
return Math.floor(seconds / 3600) + 'h ago';
},
getFreshnessClass: function(age, freshThreshold, recentThreshold) {
freshThreshold = freshThreshold || 15;
recentThreshold = recentThreshold || 45;
if (age < freshThreshold) return 'fresh';
if (age < recentThreshold) return 'recent';
return 'stale';
},
getFreshnessColor: function(freshnessClass) {
if (freshnessClass === 'fresh') return '#00c853';
if (freshnessClass === 'recent') return '#ff9800';
return '#f44336';
},
// Create a freshness indicator element
freshnessIndicator: function(age, id) {
var freshClass = this.getFreshnessClass(age);
var color = this.getFreshnessColor(freshClass);
var self = this;
return this.E('div', {
'class': 'kiss-freshness',
'id': id || 'kiss-freshness',
'style': 'display:flex; align-items:center; gap:8px; padding:6px 12px; border-radius:16px; background:var(--kiss-bg2); border:1px solid var(--kiss-line); font-size:12px;'
}, [
this.E('span', {
'class': 'kiss-fresh-dot ' + freshClass,
'id': (id || 'kiss-freshness') + '-dot',
'style': 'width:8px; height:8px; border-radius:50%; background:' + color + '; box-shadow:0 0 4px ' + color + '; transition:all 0.3s;'
}),
this.E('span', {
'id': (id || 'kiss-freshness') + '-age',
'style': 'color:' + color + '; transition:color 0.3s;'
}, age < 5 ? 'just now' : self.formatAge(age))
]);
},
// Update an existing freshness indicator
updateFreshness: function(age, id) {
id = id || 'kiss-freshness';
var freshClass = this.getFreshnessClass(age);
var color = this.getFreshnessColor(freshClass);
var dotEl = document.getElementById(id + '-dot');
var ageEl = document.getElementById(id + '-age');
if (dotEl) {
dotEl.style.background = color;
dotEl.style.boxShadow = '0 0 4px ' + color;
}
if (ageEl) {
ageEl.textContent = age < 5 ? 'just now' : this.formatAge(age);
ageEl.style.color = color;
}
},
// Render top bar // Render top bar
renderTopbar: function() { renderTopbar: function() {
var self = this; var self = this;