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:
parent
355c050700
commit
b99dabaca9
@ -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
|
||||||
|
|||||||
@ -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,13 +116,24 @@ 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; gap: 16px;' }, [
|
E('div', { 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [
|
||||||
E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'),
|
E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [
|
||||||
KissTheme.badge(s.crowdsec === 'running' ? 'RUNNING' : 'STOPPED',
|
E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'),
|
||||||
s.crowdsec === 'running' ? 'green' : 'red')
|
KissTheme.badge(s.crowdsec === 'running' ? 'RUNNING' : 'STOPPED',
|
||||||
|
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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,13 +274,18 @@ 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', { 'class': 'mx-live' }, [
|
E('div', { 'style': 'display:flex; align-items:center; gap:16px;' }, [
|
||||||
E('span', { 'class': 'mx-dot' }),
|
E('div', { 'class': 'mx-freshness', 'id': 'mx-freshness' }, [
|
||||||
'LIVE',
|
E('span', { 'class': 'mx-freshness-dot fresh', 'id': 'mx-fresh-dot' }),
|
||||||
E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString())
|
E('span', { 'class': 'mx-age fresh', 'id': 'mx-age' }, 'just now')
|
||||||
|
]),
|
||||||
|
E('div', { 'class': 'mx-live' }, [
|
||||||
|
E('span', { 'class': 'mx-dot' }),
|
||||||
|
E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString())
|
||||||
|
])
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user