diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 7fba26c9..15c2f0de 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -1,6 +1,27 @@ # 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)** - NEW: `.github/workflows/build-secubox-vm.yml` for x86_64 VM appliance images diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js index 5ee746e3..483fa37c 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js @@ -6,6 +6,19 @@ 'require crowdsec-dashboard.heatmap as heatmap'; '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({ load: function() { 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.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 = [ - // Header + // Header with freshness indicator E('div', { 'style': 'margin-bottom: 24px;' }, [ - E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [ - E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'), - KissTheme.badge(s.crowdsec === 'running' ? 'RUNNING' : 'STOPPED', - s.crowdsec === 'running' ? 'green' : 'red') + E('div', { 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [ + E('div', { 'style': 'display: flex; align-items: center; gap: 16px;' }, [ + E('h2', { 'style': 'font-size: 24px; font-weight: 700; margin: 0;' }, 'CrowdSec Dashboard'), + 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') ]), @@ -262,6 +286,23 @@ return view.extend({ s.alerts = self.parseAlerts(s); s.geoLocal = self.parseGeoData(s.geo_local_raw || s.top_countries_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'); if (el) dom.content(el, self.renderStats(s)); el = document.getElementById('cs-alerts'); diff --git a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard index fb51c279..03eb3039 100755 --- a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard +++ b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard @@ -2266,15 +2266,27 @@ save_settings() { # get_overview - returns cached data for instant response # Cache is refreshed by background cron job or refresh_overview_cache call 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 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 fi # If cache exists but stale, return stale data (better than timeout) 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 ( refresh_overview_cache ) >/dev/null 2>&1 & return 0 diff --git a/package/secubox/luci-app-metrics-dashboard/htdocs/luci-static/resources/view/metrics/dashboard.js b/package/secubox/luci-app-metrics-dashboard/htdocs/luci-static/resources/view/metrics/dashboard.js index 8a54f8b0..1e983441 100644 --- a/package/secubox/luci-app-metrics-dashboard/htdocs/luci-static/resources/view/metrics/dashboard.js +++ b/package/secubox/luci-app-metrics-dashboard/htdocs/luci-static/resources/view/metrics/dashboard.js @@ -35,6 +35,21 @@ function formatMem(kb) { 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({ load: function() { // Inject custom CSS @@ -70,6 +85,54 @@ return view.extend({ @keyframes blink { 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 */ .mx-grid { @@ -211,13 +274,18 @@ return view.extend({ var memCls = memPct > 85 ? 'red' : (memPct > 70 ? 'orange' : 'green'); var content = [ - // Header + // Header with freshness indicator E('div', { 'class': 'mx-header' }, [ E('div', { 'class': 'mx-title' }, 'Metrics Dashboard'), - E('div', { 'class': 'mx-live' }, [ - E('span', { 'class': 'mx-dot' }), - 'LIVE', - E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString()) + 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('span', { 'class': 'mx-dot' }), + E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString()) + ]) ]) ]), @@ -354,6 +422,22 @@ return view.extend({ var w = data[1] || {}; 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 = { 's-up': formatUptime(o.uptime || 0), 's-load': 'Load: ' + (o.load || '0'), @@ -372,7 +456,17 @@ return view.extend({ for (var id in upd) { 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 diff --git a/package/secubox/luci-app-metrics-dashboard/root/usr/libexec/rpcd/luci.metrics b/package/secubox/luci-app-metrics-dashboard/root/usr/libexec/rpcd/luci.metrics index db6804c6..16e1abdb 100755 --- a/package/secubox/luci-app-metrics-dashboard/root/usr/libexec/rpcd/luci.metrics +++ b/package/secubox/luci-app-metrics-dashboard/root/usr/libexec/rpcd/luci.metrics @@ -20,6 +20,14 @@ cache_is_fresh() { [ "$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() { local uptime load mem_total mem_free mem_used mem_pct @@ -88,14 +96,15 @@ build_connections() { # Build full cache build_cache() { - local overview waf conns ts + local overview waf conns ts ts_epoch overview=$(build_overview) waf=$(build_waf_stats) conns=$(build_connections) ts=$(date -Iseconds) + ts_epoch=$(date +%s) - printf '{"overview":%s,"waf":%s,"connections":%s,"timestamp":"%s"}' \ - "$overview" "$waf" "$conns" "$ts" > "$CACHE_FILE" + printf '{"overview":%s,"waf":%s,"connections":%s,"timestamp":"%s","timestamp_epoch":%d}' \ + "$overview" "$waf" "$conns" "$ts" "$ts_epoch" > "$CACHE_FILE" } # Refresh cache in background if stale @@ -117,29 +126,61 @@ get_cached() { fi } -# Fast getters from cache -get_overview() { +# Get freshness metadata +get_freshness() { + local age ts ts_epoch 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 - build_overview + age=999 + ts="" + ts_epoch=0 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() { + local data freshness 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 - build_waf_stats + data=$(build_waf_stats) fi + freshness=$(get_freshness) + printf '%s' "$data" | sed 's/}$//' + printf ',"_freshness":%s}' "$freshness" } get_connections() { + local data freshness 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 - build_connections + data=$(build_connections) fi + freshness=$(get_freshness) + printf '%s' "$data" | sed 's/}$//' + printf ',"_freshness":%s}' "$freshness" } # Simple getters (less critical, can compute) diff --git a/package/secubox/luci-theme-secubox/htdocs/luci-static/resources/secubox/kiss-theme.js b/package/secubox/luci-theme-secubox/htdocs/luci-static/resources/secubox/kiss-theme.js index be3a8965..ac60252a 100644 --- a/package/secubox/luci-theme-secubox/htdocs/luci-static/resources/secubox/kiss-theme.js +++ b/package/secubox/luci-theme-secubox/htdocs/luci-static/resources/secubox/kiss-theme.js @@ -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); }, + // 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 renderTopbar: function() { var self = this;