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
|
||||
|
||||
_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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user