feat: .GOV Domain Tracker tab on RADAR page
- Add govdomains_sync.py: clones CISA dotgov-data, parses CSV, tracks first_seen dates - Add /api/govdomains and /api/govdomains/stats Flask endpoints with range/type/search filters - Add NEWS FEED | .GOV TRACKER toggle to RADAR page - Domain type badges (Federal=red, State=blue, City=green, County=amber) - New domain detection with pulsing green highlight and NEW badge - Responsive grid layout with stats bar and result count
This commit is contained in:
parent
acb52eb06a
commit
2bc40ac285
5 changed files with 1058 additions and 147 deletions
115
api/app.py
115
api/app.py
|
|
@ -1410,5 +1410,120 @@ def api_sitrep_generate():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
# ─── .GOV Domain Tracker ──────────────────────────────
|
||||||
|
|
||||||
|
def _load_govdomains():
|
||||||
|
p = DATA_DIR / 'govdomains.json'
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {'last_sync': None, 'total': 0, 'domains': []}
|
||||||
|
|
||||||
|
@app.route('/api/govdomains')
|
||||||
|
def api_govdomains():
|
||||||
|
"""Return .gov domains with optional filters: range, search, type, new_only."""
|
||||||
|
data = _load_govdomains()
|
||||||
|
domains = data.get('domains', [])
|
||||||
|
|
||||||
|
# Range filter (by first_seen)
|
||||||
|
range_param = request.args.get('range', 'all').lower()
|
||||||
|
if range_param != 'all':
|
||||||
|
range_map = {
|
||||||
|
'24h': 1, '3d': 3, '7d': 7, '14d': 14, '30d': 30
|
||||||
|
}
|
||||||
|
days = range_map.get(range_param, None)
|
||||||
|
if days:
|
||||||
|
from datetime import datetime as dt, timedelta
|
||||||
|
cutoff = (dt.utcnow() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
domains = [d for d in domains if d.get('first_seen', '') >= cutoff]
|
||||||
|
|
||||||
|
# Type filter
|
||||||
|
type_param = request.args.get('type', '').strip()
|
||||||
|
if type_param:
|
||||||
|
type_lower = type_param.lower()
|
||||||
|
domains = [d for d in domains if d.get('type', '').lower() == type_lower]
|
||||||
|
|
||||||
|
# New only filter
|
||||||
|
if request.args.get('new_only', '').lower() == 'true':
|
||||||
|
domains = [d for d in domains if d.get('is_new', False)]
|
||||||
|
|
||||||
|
# Search filter
|
||||||
|
search = request.args.get('search', '').strip().lower()
|
||||||
|
if search:
|
||||||
|
domains = [d for d in domains if (
|
||||||
|
search in d.get('domain', '').lower() or
|
||||||
|
search in d.get('agency', '').lower() or
|
||||||
|
search in d.get('organization', '').lower() or
|
||||||
|
search in d.get('city', '').lower() or
|
||||||
|
search in d.get('state', '').lower()
|
||||||
|
)]
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
limit = min(int(request.args.get('limit', 500)), 2000)
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'last_sync': data.get('last_sync'),
|
||||||
|
'total_all': data.get('total', 0),
|
||||||
|
'total_filtered': len(domains),
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
'domains': domains[offset:offset + limit]
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/govdomains/stats')
|
||||||
|
def api_govdomains_stats():
|
||||||
|
"""Return summary stats for .gov domains."""
|
||||||
|
data = _load_govdomains()
|
||||||
|
domains = data.get('domains', [])
|
||||||
|
|
||||||
|
# Count by type
|
||||||
|
type_counts = {}
|
||||||
|
for d in domains:
|
||||||
|
t = d.get('type', 'Unknown') or 'Unknown'
|
||||||
|
type_counts[t] = type_counts.get(t, 0) + 1
|
||||||
|
|
||||||
|
# Count new (24h)
|
||||||
|
from datetime import datetime as dt, timedelta
|
||||||
|
cutoff_24h = (dt.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
cutoff_7d = (dt.utcnow() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||||
|
cutoff_30d = (dt.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
new_24h = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_24h)
|
||||||
|
new_7d = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_7d)
|
||||||
|
new_30d = sum(1 for d in domains if d.get('first_seen', '') >= cutoff_30d)
|
||||||
|
|
||||||
|
# Load history for recent additions timeline
|
||||||
|
hist_path = DATA_DIR / 'govdomains_history.json'
|
||||||
|
history = {}
|
||||||
|
if hist_path.exists():
|
||||||
|
try:
|
||||||
|
with open(hist_path) as f:
|
||||||
|
history = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Recent history (last 30 days)
|
||||||
|
recent_history = {}
|
||||||
|
for date_key, entries in history.items():
|
||||||
|
if date_key >= cutoff_30d:
|
||||||
|
count = len([e for e in entries if not e.startswith('__baseline__')])
|
||||||
|
if count > 0:
|
||||||
|
recent_history[date_key] = count
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'total': data.get('total', 0),
|
||||||
|
'last_sync': data.get('last_sync'),
|
||||||
|
'new_24h': new_24h,
|
||||||
|
'new_7d': new_7d,
|
||||||
|
'new_30d': new_30d,
|
||||||
|
'by_type': type_counts,
|
||||||
|
'recent_additions': recent_history,
|
||||||
|
'types_list': sorted(type_counts.keys())
|
||||||
|
})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||||
|
|
|
||||||
228
api/govdomains_sync.py
Normal file
228
api/govdomains_sync.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""JAESWIFT .GOV Domain Tracker — Sync Script
|
||||||
|
Clones/pulls cisagov/dotgov-data, parses current-full.csv,
|
||||||
|
tracks first_seen dates and new domain additions.
|
||||||
|
Designed to run via cron every 12 hours.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ─── Configuration ─────────────────────────────────────
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
SOURCE_DIR = BASE_DIR / 'govdomains-source'
|
||||||
|
DATA_DIR = BASE_DIR / 'data'
|
||||||
|
DOMAINS_FILE = DATA_DIR / 'govdomains.json'
|
||||||
|
HISTORY_FILE = DATA_DIR / 'govdomains_history.json'
|
||||||
|
REPO_URL = 'https://github.com/cisagov/dotgov-data.git'
|
||||||
|
CSV_FILE = SOURCE_DIR / 'current-full.csv'
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
ts = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
print(f'[{ts}] {msg}', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_or_pull():
|
||||||
|
"""Clone the CISA dotgov-data repo or pull latest changes."""
|
||||||
|
if (SOURCE_DIR / '.git').exists():
|
||||||
|
log('Pulling latest dotgov-data...')
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', str(SOURCE_DIR), 'pull', '--ff-only'],
|
||||||
|
capture_output=True, text=True, timeout=120
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
log(f'Git pull failed: {result.stderr.strip()}')
|
||||||
|
# Force reset
|
||||||
|
subprocess.run(
|
||||||
|
['git', '-C', str(SOURCE_DIR), 'fetch', 'origin'],
|
||||||
|
capture_output=True, text=True, timeout=120
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
['git', '-C', str(SOURCE_DIR), 'reset', '--hard', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
log('Force-reset to origin/main')
|
||||||
|
else:
|
||||||
|
log(f'Pull OK: {result.stdout.strip()}')
|
||||||
|
else:
|
||||||
|
log('Cloning dotgov-data repo...')
|
||||||
|
SOURCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'clone', '--depth', '1', REPO_URL, str(SOURCE_DIR)],
|
||||||
|
capture_output=True, text=True, timeout=300
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
log(f'Clone failed: {result.stderr.strip()}')
|
||||||
|
sys.exit(1)
|
||||||
|
log('Clone complete')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv():
|
||||||
|
"""Parse current-full.csv and return list of domain dicts."""
|
||||||
|
if not CSV_FILE.exists():
|
||||||
|
log(f'CSV not found: {CSV_FILE}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
domains = []
|
||||||
|
with open(CSV_FILE, 'r', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
domain = (row.get('Domain Name') or row.get('Domain name') or '').strip().lower()
|
||||||
|
if not domain:
|
||||||
|
continue
|
||||||
|
# Ensure .gov suffix
|
||||||
|
if not domain.endswith('.gov'):
|
||||||
|
domain += '.gov'
|
||||||
|
domains.append({
|
||||||
|
'domain': domain,
|
||||||
|
'type': (row.get('Domain Type') or row.get('Domain type') or '').strip(),
|
||||||
|
'agency': (row.get('Agency') or '').strip(),
|
||||||
|
'organization': (row.get('Organization') or row.get('Organization name') or '').strip(),
|
||||||
|
'city': (row.get('City') or '').strip(),
|
||||||
|
'state': (row.get('State') or '').strip(),
|
||||||
|
'security_contact': (row.get('Security Contact Email') or row.get('Security contact email') or '').strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
log(f'Parsed {len(domains)} domains from CSV')
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing():
|
||||||
|
"""Load existing govdomains.json if it exists."""
|
||||||
|
if DOMAINS_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(DOMAINS_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
log(f'Error loading existing data: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_history():
|
||||||
|
"""Load domain addition history."""
|
||||||
|
if HISTORY_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(HISTORY_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def sync():
|
||||||
|
"""Main sync logic."""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
now_iso = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Clone or pull the repo
|
||||||
|
clone_or_pull()
|
||||||
|
|
||||||
|
# Parse fresh CSV data
|
||||||
|
csv_domains = parse_csv()
|
||||||
|
if not csv_domains:
|
||||||
|
log('No domains parsed — aborting')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Build lookup from CSV: domain -> record
|
||||||
|
csv_lookup = {}
|
||||||
|
for d in csv_domains:
|
||||||
|
csv_lookup[d['domain']] = d
|
||||||
|
|
||||||
|
# Load existing data
|
||||||
|
existing = load_existing()
|
||||||
|
history = load_history()
|
||||||
|
|
||||||
|
# Build lookup of existing domains: domain -> record (with first_seen)
|
||||||
|
existing_lookup = {}
|
||||||
|
if existing and 'domains' in existing:
|
||||||
|
for d in existing['domains']:
|
||||||
|
existing_lookup[d['domain']] = d
|
||||||
|
|
||||||
|
# Merge: preserve first_seen for known domains, mark new ones
|
||||||
|
new_domains_today = []
|
||||||
|
merged = []
|
||||||
|
|
||||||
|
for domain_name, csv_record in csv_lookup.items():
|
||||||
|
if domain_name in existing_lookup:
|
||||||
|
# Existing domain — preserve first_seen, update other fields
|
||||||
|
entry = {
|
||||||
|
**csv_record,
|
||||||
|
'first_seen': existing_lookup[domain_name].get('first_seen', today),
|
||||||
|
'is_new': False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# New domain
|
||||||
|
entry = {
|
||||||
|
**csv_record,
|
||||||
|
'first_seen': today,
|
||||||
|
'is_new': True,
|
||||||
|
}
|
||||||
|
new_domains_today.append(domain_name)
|
||||||
|
|
||||||
|
merged.append(entry)
|
||||||
|
|
||||||
|
# Sort by first_seen descending then domain name
|
||||||
|
merged.sort(key=lambda x: (x['first_seen'], x['domain']), reverse=True)
|
||||||
|
|
||||||
|
# On first run, don't mark everything as "new" in history
|
||||||
|
# (all domains get first_seen=today but that's expected)
|
||||||
|
is_first_run = existing is None
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
output = {
|
||||||
|
'last_sync': now_iso,
|
||||||
|
'total': len(merged),
|
||||||
|
'new_today': len(new_domains_today),
|
||||||
|
'is_first_run': is_first_run,
|
||||||
|
'domains': merged,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save domains file
|
||||||
|
with open(DOMAINS_FILE, 'w') as f:
|
||||||
|
json.dump(output, f, indent=2)
|
||||||
|
log(f'Saved {len(merged)} domains to {DOMAINS_FILE}')
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
if new_domains_today and not is_first_run:
|
||||||
|
if today not in history:
|
||||||
|
history[today] = []
|
||||||
|
# Append without duplicating
|
||||||
|
existing_in_day = set(history[today])
|
||||||
|
for d in new_domains_today:
|
||||||
|
if d not in existing_in_day:
|
||||||
|
history[today].append(d)
|
||||||
|
log(f'Recorded {len(new_domains_today)} new domains for {today}')
|
||||||
|
elif is_first_run:
|
||||||
|
# First run — record total as baseline, not individual domains
|
||||||
|
history[today] = [f'__baseline__:{len(merged)}_domains']
|
||||||
|
log(f'First run — baseline of {len(merged)} domains established')
|
||||||
|
|
||||||
|
with open(HISTORY_FILE, 'w') as f:
|
||||||
|
json.dump(history, f, indent=2)
|
||||||
|
log(f'History updated: {HISTORY_FILE}')
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
log(f'Sync complete: {len(merged)} total domains, {len(new_domains_today)} new today')
|
||||||
|
if new_domains_today and len(new_domains_today) <= 20:
|
||||||
|
for d in new_domains_today:
|
||||||
|
log(f' NEW: {d}')
|
||||||
|
elif new_domains_today:
|
||||||
|
log(f' (too many to list individually)')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
sync()
|
||||||
|
except Exception as e:
|
||||||
|
log(f'FATAL ERROR: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
306
css/radar.css
306
css/radar.css
|
|
@ -1,4 +1,4 @@
|
||||||
/* ─── RADAR: Live Intelligence Feed ─────────────── */
|
/* ─── RADAR: Live Intelligence Feed + .GOV Domain Tracker ─── */
|
||||||
|
|
||||||
.radar-container {
|
.radar-container {
|
||||||
max-width: clamp(1200px, 90vw, 2400px);
|
max-width: clamp(1200px, 90vw, 2400px);
|
||||||
|
|
@ -6,6 +6,61 @@
|
||||||
padding: 0 2rem 3rem;
|
padding: 0 2rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── View Toggle ──────────────────────────────── */
|
||||||
|
.radar-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-view-btn {
|
||||||
|
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-view-btn:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-view-btn:hover {
|
||||||
|
background: rgba(255, 170, 0, 0.04);
|
||||||
|
color: rgba(255, 170, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-view-btn.active {
|
||||||
|
background: rgba(255, 170, 0, 0.08);
|
||||||
|
color: rgba(255, 170, 0, 0.95);
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(255, 170, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── View Containers ──────────────────────────── */
|
||||||
|
.radar-view {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-view.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Controls Bar ─────────────────────────────── */
|
/* ─── Controls Bar ─────────────────────────────── */
|
||||||
.radar-controls {
|
.radar-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -22,7 +77,8 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-filter {
|
.radar-filter,
|
||||||
|
.gov-range {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.5px;
|
||||||
|
|
@ -34,13 +90,15 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-filter:hover {
|
.radar-filter:hover,
|
||||||
|
.gov-range:hover {
|
||||||
background: rgba(255, 170, 0, 0.06);
|
background: rgba(255, 170, 0, 0.06);
|
||||||
border-color: rgba(255, 170, 0, 0.2);
|
border-color: rgba(255, 170, 0, 0.2);
|
||||||
color: rgba(255, 170, 0, 0.7);
|
color: rgba(255, 170, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-filter.active {
|
.radar-filter.active,
|
||||||
|
.gov-range.active {
|
||||||
background: rgba(255, 170, 0, 0.08);
|
background: rgba(255, 170, 0, 0.08);
|
||||||
border-color: rgba(255, 170, 0, 0.4);
|
border-color: rgba(255, 170, 0, 0.4);
|
||||||
color: rgba(255, 170, 0, 0.9);
|
color: rgba(255, 170, 0, 0.9);
|
||||||
|
|
@ -100,6 +158,36 @@
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Type Filter Dropdown ─────────────────────── */
|
||||||
|
.gov-type-filter {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 160px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,170,0,0.5)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
padding-right: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-type-filter:focus {
|
||||||
|
border-color: rgba(255, 170, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-type-filter option {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Stats Bar ────────────────────────────────── */
|
/* ─── Stats Bar ────────────────────────────────── */
|
||||||
.radar-stats {
|
.radar-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -126,6 +214,11 @@
|
||||||
color: rgba(0, 204, 68, 0.7);
|
color: rgba(0, 204, 68, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-new {
|
||||||
|
color: rgba(0, 204, 68, 0.9);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.radar-live {
|
.radar-live {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -142,12 +235,27 @@
|
||||||
animation: pulse-dot 1.5s ease infinite;
|
animation: pulse-dot 1.5s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gov-dot {
|
||||||
|
background: #00cc44;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px #00cc44; }
|
0%, 100% { opacity: 1; box-shadow: 0 0 4px #00cc44; }
|
||||||
50% { opacity: 0.4; box-shadow: 0 0 1px #00cc44; }
|
50% { opacity: 0.4; box-shadow: 0 0 1px #00cc44; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Feed Items ───────────────────────────────── */
|
/* ─── Gov Results Bar ──────────────────────────── */
|
||||||
|
.gov-results-bar {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Feed Items (News) ────────────────────────── */
|
||||||
.radar-feed {
|
.radar-feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -276,7 +384,162 @@
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════ */
|
||||||
|
/* .GOV DOMAIN TRACKER STYLES */
|
||||||
|
/* ═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─── Domain Item ──────────────────────────────── */
|
||||||
|
.gov-domain-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
transition: background 0.15s, border-color 0.3s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-domain-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── New Domain Highlight ─────────────────────── */
|
||||||
|
.gov-domain-item.gov-new {
|
||||||
|
border-left: 2px solid rgba(0, 204, 68, 0.6);
|
||||||
|
background: rgba(0, 204, 68, 0.02);
|
||||||
|
animation: gov-new-pulse 3s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gov-new-pulse {
|
||||||
|
0%, 100% { background: rgba(0, 204, 68, 0.02); }
|
||||||
|
50% { background: rgba(0, 204, 68, 0.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-domain-item.gov-new:hover {
|
||||||
|
background: rgba(0, 204, 68, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Domain Name ──────────────────────────────── */
|
||||||
|
.gov-domain-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-domain-name {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-domain-name:hover {
|
||||||
|
color: rgba(255, 170, 0, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── NEW Badge ────────────────────────────────── */
|
||||||
|
.gov-new-badge {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: rgba(0, 204, 68, 0.15);
|
||||||
|
border: 1px solid rgba(0, 204, 68, 0.4);
|
||||||
|
color: #00cc44;
|
||||||
|
animation: badge-glow 2s ease infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px rgba(0, 204, 68, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 10px rgba(0, 204, 68, 0.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Type Badge ───────────────────────────────── */
|
||||||
|
.gov-domain-type {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Domain Details ───────────────────────────── */
|
||||||
|
.gov-domain-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-detail {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gov-detail-label {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── First Seen Date ──────────────────────────── */
|
||||||
|
.gov-domain-date {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Truncation Notice ────────────────────────── */
|
||||||
|
.gov-truncated {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 170, 0, 0.4);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
border-top: 1px solid rgba(255, 170, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Responsive ───────────────────────────────── */
|
/* ─── Responsive ───────────────────────────────── */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.gov-domain-item {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
.gov-domain-details {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.gov-domain-date {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.radar-controls {
|
.radar-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -300,17 +563,46 @@
|
||||||
.radar-stats {
|
.radar-stats {
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.radar-view-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.radar-view-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
.gov-domain-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
.gov-domain-type {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.gov-domain-date {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.gov-type-filter {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.radar-container {
|
.radar-container {
|
||||||
padding: 0 1rem 2rem;
|
padding: 0 1rem 2rem;
|
||||||
}
|
}
|
||||||
.radar-filters {
|
.radar-filters,
|
||||||
|
.gov-range-filters {
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
.radar-filter {
|
.radar-filter,
|
||||||
|
.gov-range {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
.gov-detail {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
409
js/radar.js
409
js/radar.js
|
|
@ -1,14 +1,17 @@
|
||||||
/* ─── RADAR: Live Intelligence Feed ─────────────── */
|
/* ─── RADAR: Live Intelligence Feed + .GOV Domain Tracker ─── */
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const API = '/api/radar';
|
// ═══════════════════════════════════════════════════
|
||||||
const REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes
|
// SHARED UTILITIES
|
||||||
let currentSource = 'all';
|
// ═══════════════════════════════════════════════════
|
||||||
let allItems = [];
|
|
||||||
let refreshTimer = null;
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Time Ago ──────────────────────────────────
|
|
||||||
function timeAgo(dateStr) {
|
function timeAgo(dateStr) {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -21,146 +24,368 @@
|
||||||
return Math.floor(diff / 604800) + 'w ago';
|
return Math.floor(diff / 604800) + 'w ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Extract Domain ────────────────────────────
|
|
||||||
function extractDomain(url) {
|
function extractDomain(url) {
|
||||||
try {
|
try { return new URL(url).hostname.replace('www.', ''); }
|
||||||
const u = new URL(url);
|
catch(e) { return ''; }
|
||||||
return u.hostname.replace('www.', '');
|
|
||||||
} catch(e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Escape HTML ───────────────────────────────
|
// ═══════════════════════════════════════════════════
|
||||||
function esc(s) {
|
// NEWS FEED MODULE
|
||||||
const d = document.createElement('div');
|
// ═══════════════════════════════════════════════════
|
||||||
d.textContent = s;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Render Feed ───────────────────────────────
|
const NewsFeed = {
|
||||||
function renderFeed(items) {
|
API: '/api/radar',
|
||||||
|
REFRESH_INTERVAL: 15 * 60 * 1000,
|
||||||
|
currentSource: 'all',
|
||||||
|
allItems: [],
|
||||||
|
refreshTimer: null,
|
||||||
|
|
||||||
|
renderFeed: function(items) {
|
||||||
const feed = document.getElementById('radarFeed');
|
const feed = document.getElementById('radarFeed');
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>';
|
feed.innerHTML = '<div class="radar-empty">NO SIGNALS DETECTED ON CURRENT FREQUENCY</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
items.forEach(item => {
|
items.forEach(function(item) {
|
||||||
const domain = extractDomain(item.url);
|
const domain = extractDomain(item.url);
|
||||||
const ago = timeAgo(item.published);
|
const ago = timeAgo(item.published);
|
||||||
const sourceClass = 'source-' + item.source_id;
|
const sourceClass = 'source-' + item.source_id;
|
||||||
const sourceLabel = item.source || 'UNKNOWN';
|
const sourceLabel = item.source || 'UNKNOWN';
|
||||||
|
|
||||||
html += '<div class="radar-item">';
|
html += '<div class="radar-item">';
|
||||||
html += ' <div class="radar-item-time">' + esc(ago) + '</div>';
|
html += ' <div class="radar-item-time">' + esc(ago) + '</div>';
|
||||||
html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>';
|
html += ' <div class="radar-item-source ' + sourceClass + '">' + esc(sourceLabel) + '</div>';
|
||||||
html += ' <div class="radar-item-content">';
|
html += ' <div class="radar-item-content">';
|
||||||
html += ' <a href="' + esc(item.url) + '" class="radar-item-title" target="_blank" rel="noopener">' + esc(item.title) + '</a>';
|
html += ' <a href="' + esc(item.url) + '" class="radar-item-title" target="_blank" rel="noopener">' + esc(item.title) + '</a>';
|
||||||
html += ' <div class="radar-item-meta">';
|
html += ' <div class="radar-item-meta">';
|
||||||
if (domain) {
|
if (domain) html += '<span class="radar-item-domain">' + esc(domain) + '</span>';
|
||||||
html += ' <span class="radar-item-domain">' + esc(domain) + '</span>';
|
if (item.comments_url) html += '<a href="' + esc(item.comments_url) + '" class="radar-item-comments" target="_blank" rel="noopener">COMMENTS</a>';
|
||||||
}
|
html += ' </div></div></div>';
|
||||||
if (item.comments_url) {
|
|
||||||
html += ' <a href="' + esc(item.comments_url) + '" class="radar-item-comments" target="_blank" rel="noopener">COMMENTS</a>';
|
|
||||||
}
|
|
||||||
html += ' </div>';
|
|
||||||
html += ' </div>';
|
|
||||||
html += '</div>';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
feed.innerHTML = html;
|
feed.innerHTML = html;
|
||||||
}
|
},
|
||||||
|
|
||||||
// ─── Filter & Search ───────────────────────────
|
applyFilters: function() {
|
||||||
function applyFilters() {
|
var q = document.getElementById('radarSearch').value.trim().toLowerCase();
|
||||||
const q = document.getElementById('radarSearch').value.trim().toLowerCase();
|
var filtered = this.allItems;
|
||||||
let filtered = allItems;
|
if (this.currentSource !== 'all') {
|
||||||
|
filtered = filtered.filter(function(i) { return i.source_id === NewsFeed.currentSource; });
|
||||||
if (currentSource !== 'all') {
|
|
||||||
filtered = filtered.filter(i => i.source_id === currentSource);
|
|
||||||
}
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
filtered = filtered.filter(i =>
|
filtered = filtered.filter(function(i) {
|
||||||
(i.title || '').toLowerCase().includes(q) ||
|
return (i.title || '').toLowerCase().includes(q) ||
|
||||||
(i.summary || '').toLowerCase().includes(q)
|
(i.summary || '').toLowerCase().includes(q);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('statTotal').textContent = filtered.length;
|
document.getElementById('statTotal').textContent = filtered.length;
|
||||||
renderFeed(filtered);
|
this.renderFeed(filtered);
|
||||||
}
|
},
|
||||||
|
|
||||||
// ─── Fetch Data ────────────────────────────────
|
fetch: function(forceRefresh) {
|
||||||
async function fetchRadar(forceRefresh) {
|
var self = this;
|
||||||
const feed = document.getElementById('radarFeed');
|
var feed = document.getElementById('radarFeed');
|
||||||
feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>';
|
feed.innerHTML = '<div class="radar-loading">SCANNING FREQUENCIES...</div>';
|
||||||
|
var chain = forceRefresh
|
||||||
try {
|
? fetch(this.API + '/refresh', { method: 'POST' }).then(function() { return fetch(self.API); })
|
||||||
if (forceRefresh) {
|
: fetch(this.API);
|
||||||
await fetch(API + '/refresh', { method: 'POST' });
|
chain.then(function(res) { return res.json(); })
|
||||||
}
|
.then(function(data) {
|
||||||
|
self.allItems = data.items || [];
|
||||||
const res = await fetch(API);
|
document.getElementById('statTotal').textContent = self.allItems.length;
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
allItems = data.items || [];
|
|
||||||
document.getElementById('statTotal').textContent = allItems.length;
|
|
||||||
|
|
||||||
// Format last updated
|
|
||||||
if (data.last_updated) {
|
if (data.last_updated) {
|
||||||
const d = new Date(data.last_updated);
|
var d = new Date(data.last_updated);
|
||||||
const pad = n => String(n).padStart(2, '0');
|
var pad = function(n) { return String(n).padStart(2, '0'); };
|
||||||
document.getElementById('statUpdated').textContent =
|
document.getElementById('statUpdated').textContent =
|
||||||
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC';
|
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' UTC';
|
||||||
}
|
}
|
||||||
|
self.applyFilters();
|
||||||
applyFilters();
|
})
|
||||||
|
.catch(function(err) {
|
||||||
} catch(err) {
|
|
||||||
console.error('RADAR fetch error:', err);
|
console.error('RADAR fetch error:', err);
|
||||||
feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>';
|
feed.innerHTML = '<div class="radar-empty">⚠ SIGNAL LOST — UNABLE TO REACH FEED API</div>';
|
||||||
}
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
// ─── Event Listeners ───────────────────────────
|
init: function() {
|
||||||
function init() {
|
var self = this;
|
||||||
// Source filters
|
// Source filters
|
||||||
document.querySelectorAll('.radar-filter').forEach(btn => {
|
document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function(e) {
|
btn.addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.querySelectorAll('.radar-filter').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('#viewNewsfeed .radar-filter').forEach(function(b) { b.classList.remove('active'); });
|
||||||
this.classList.add('active');
|
this.classList.add('active');
|
||||||
currentSource = this.dataset.source;
|
self.currentSource = this.dataset.source;
|
||||||
applyFilters();
|
self.applyFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
let searchTimeout;
|
var searchTimeout;
|
||||||
document.getElementById('radarSearch').addEventListener('input', function() {
|
document.getElementById('radarSearch').addEventListener('input', function() {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(applyFilters, 200);
|
searchTimeout = setTimeout(function() { self.applyFilters(); }, 200);
|
||||||
});
|
});
|
||||||
|
// Refresh
|
||||||
// Refresh button
|
|
||||||
document.getElementById('radarRefresh').addEventListener('click', function(e) {
|
document.getElementById('radarRefresh').addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.classList.add('spinning');
|
this.classList.add('spinning');
|
||||||
fetchRadar(true).then(() => {
|
var btn = this;
|
||||||
setTimeout(() => this.classList.remove('spinning'), 500);
|
self.fetch(true).then(function() {
|
||||||
|
setTimeout(function() { btn.classList.remove('spinning'); }, 500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchRadar(false);
|
this.fetch(false);
|
||||||
|
// Auto-refresh
|
||||||
|
this.refreshTimer = setInterval(function() { self.fetch(false); }, this.REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-refresh every 15 min
|
// ═══════════════════════════════════════════════════
|
||||||
refreshTimer = setInterval(() => fetchRadar(false), REFRESH_INTERVAL);
|
// .GOV TRACKER MODULE
|
||||||
|
// ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const GovTracker = {
|
||||||
|
API: '/api/govdomains',
|
||||||
|
STATS_API: '/api/govdomains/stats',
|
||||||
|
currentRange: 'all',
|
||||||
|
currentType: '',
|
||||||
|
currentSearch: '',
|
||||||
|
allDomains: [],
|
||||||
|
statsData: null,
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
TYPE_COLORS: {
|
||||||
|
'Federal': { color: '#ff4444', bg: 'rgba(255,68,68,0.08)', border: 'rgba(255,68,68,0.35)' },
|
||||||
|
'Executive': { color: '#ff4444', bg: 'rgba(255,68,68,0.08)', border: 'rgba(255,68,68,0.35)' },
|
||||||
|
'Judicial': { color: '#ff6b6b', bg: 'rgba(255,107,107,0.08)', border: 'rgba(255,107,107,0.3)' },
|
||||||
|
'Legislative': { color: '#ff6b6b', bg: 'rgba(255,107,107,0.08)', border: 'rgba(255,107,107,0.3)' },
|
||||||
|
'State': { color: '#4488ff', bg: 'rgba(68,136,255,0.08)', border: 'rgba(68,136,255,0.35)' },
|
||||||
|
'Interstate': { color: '#44aaff', bg: 'rgba(68,170,255,0.08)', border: 'rgba(68,170,255,0.3)' },
|
||||||
|
'City': { color: '#00cc44', bg: 'rgba(0,204,68,0.08)', border: 'rgba(0,204,68,0.35)' },
|
||||||
|
'County': { color: '#ffaa00', bg: 'rgba(255,170,0,0.08)', border: 'rgba(255,170,0,0.35)' },
|
||||||
|
'Independent Intrastate': { color: '#bb88ff', bg: 'rgba(187,136,255,0.08)', border: 'rgba(187,136,255,0.3)' },
|
||||||
|
'Native Sovereign Nation': { color: '#ff8844', bg: 'rgba(255,136,68,0.08)', border: 'rgba(255,136,68,0.3)' }
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypeStyle: function(type) {
|
||||||
|
return this.TYPE_COLORS[type] || { color: '#888', bg: 'rgba(136,136,136,0.08)', border: 'rgba(136,136,136,0.3)' };
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDomains: function(domains) {
|
||||||
|
var feed = document.getElementById('govFeed');
|
||||||
|
if (!domains || domains.length === 0) {
|
||||||
|
feed.innerHTML = '<div class="radar-empty">NO .GOV DOMAINS MATCH CURRENT FILTERS</div>';
|
||||||
|
document.getElementById('govResultCount').textContent = '0 RESULTS';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show count
|
||||||
|
document.getElementById('govResultCount').textContent =
|
||||||
|
domains.length.toLocaleString() + ' DOMAIN' + (domains.length !== 1 ? 'S' : '') + ' FOUND';
|
||||||
|
|
||||||
|
// Only render first 200 for performance
|
||||||
|
var visible = domains.slice(0, 200);
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
visible.forEach(function(d) {
|
||||||
|
var style = GovTracker.getTypeStyle(d.type);
|
||||||
|
var isNew = d.is_new;
|
||||||
|
var itemClass = 'gov-domain-item' + (isNew ? ' gov-new' : '');
|
||||||
|
|
||||||
|
html += '<div class="' + itemClass + '">';
|
||||||
|
|
||||||
|
// Domain name + link
|
||||||
|
html += '<div class="gov-domain-main">';
|
||||||
|
html += ' <a href="https://' + esc(d.domain) + '" class="gov-domain-name" target="_blank" rel="noopener">';
|
||||||
|
html += esc(d.domain);
|
||||||
|
html += ' </a>';
|
||||||
|
if (isNew) html += ' <span class="gov-new-badge">NEW</span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Type badge
|
||||||
|
html += '<div class="gov-domain-type" style="color:' + style.color + ';background:' + style.bg + ';border-color:' + style.border + '">';
|
||||||
|
html += esc(d.type || 'UNKNOWN');
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Details
|
||||||
|
html += '<div class="gov-domain-details">';
|
||||||
|
if (d.agency) html += '<span class="gov-detail"><span class="gov-detail-label">AGENCY:</span> ' + esc(d.agency) + '</span>';
|
||||||
|
if (d.organization) html += '<span class="gov-detail"><span class="gov-detail-label">ORG:</span> ' + esc(d.organization) + '</span>';
|
||||||
|
if (d.city || d.state) {
|
||||||
|
var loc = [d.city, d.state].filter(Boolean).join(', ');
|
||||||
|
html += '<span class="gov-detail"><span class="gov-detail-label">LOC:</span> ' + esc(loc) + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// First seen
|
||||||
|
html += '<div class="gov-domain-date">' + esc(d.first_seen || '—') + '</div>';
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domains.length > 200) {
|
||||||
|
html += '<div class="gov-truncated">SHOWING 200 OF ' + domains.length.toLocaleString() + ' — REFINE SEARCH TO SEE MORE</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchStats: function() {
|
||||||
|
var self = this;
|
||||||
|
return fetch(this.STATS_API)
|
||||||
|
.then(function(res) { return res.json(); })
|
||||||
|
.then(function(stats) {
|
||||||
|
self.statsData = stats;
|
||||||
|
document.getElementById('govStatTotal').textContent = (stats.total || 0).toLocaleString();
|
||||||
|
document.getElementById('govStatNew24').textContent = (stats.new_24h || 0).toLocaleString();
|
||||||
|
document.getElementById('govStatFederal').textContent = ((stats.by_type || {})['Federal'] || 0).toLocaleString();
|
||||||
|
document.getElementById('govStatState').textContent = ((stats.by_type || {})['State'] || 0).toLocaleString();
|
||||||
|
|
||||||
|
if (stats.last_sync) {
|
||||||
|
var d = new Date(stats.last_sync);
|
||||||
|
var pad = function(n) { return String(n).padStart(2, '0'); };
|
||||||
|
document.getElementById('govStatSync').textContent =
|
||||||
|
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ' UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate type filter dropdown
|
||||||
|
var select = document.getElementById('govTypeFilter');
|
||||||
|
var currentVal = select.value;
|
||||||
|
// Clear existing options except first
|
||||||
|
while (select.options.length > 1) select.remove(1);
|
||||||
|
(stats.types_list || []).forEach(function(t) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t.toUpperCase() + ' (' + ((stats.by_type || {})[t] || 0) + ')';
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (currentVal) select.value = currentVal;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('Gov stats error:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchDomains: function() {
|
||||||
|
var self = this;
|
||||||
|
var feed = document.getElementById('govFeed');
|
||||||
|
feed.innerHTML = '<div class="radar-loading">QUERYING .GOV REGISTRY...</div>';
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
if (this.currentRange !== 'all') params.set('range', this.currentRange);
|
||||||
|
if (this.currentType) params.set('type', this.currentType);
|
||||||
|
if (this.currentSearch) params.set('search', this.currentSearch);
|
||||||
|
params.set('limit', '2000');
|
||||||
|
|
||||||
|
var url = this.API + '?' + params.toString();
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(function(res) { return res.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
self.allDomains = data.domains || [];
|
||||||
|
self.renderDomains(self.allDomains);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('Gov domains error:', err);
|
||||||
|
feed.innerHTML = '<div class="radar-empty">⚠ UNABLE TO QUERY .GOV REGISTRY</div>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAll: function() {
|
||||||
|
var self = this;
|
||||||
|
return Promise.all([this.fetchStats(), this.fetchDomains()]).then(function() {
|
||||||
|
self.loaded = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Range filters
|
||||||
|
document.querySelectorAll('.gov-range').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('.gov-range').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
this.classList.add('active');
|
||||||
|
self.currentRange = this.dataset.range;
|
||||||
|
self.fetchDomains();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
document.getElementById('govTypeFilter').addEventListener('change', function() {
|
||||||
|
self.currentType = this.value;
|
||||||
|
self.fetchDomains();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
var searchTimeout;
|
||||||
|
document.getElementById('govSearch').addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
var val = this.value.trim();
|
||||||
|
searchTimeout = setTimeout(function() {
|
||||||
|
self.currentSearch = val;
|
||||||
|
self.fetchDomains();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
document.getElementById('govRefresh').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.add('spinning');
|
||||||
|
var btn = this;
|
||||||
|
self.loadAll().then(function() {
|
||||||
|
setTimeout(function() { btn.classList.remove('spinning'); }, 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════
|
||||||
|
// VIEW TOGGLE CONTROLLER
|
||||||
|
// ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function initViewToggle() {
|
||||||
|
var subtitles = {
|
||||||
|
newsfeed: '> Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs',
|
||||||
|
govtracker: '> .GOV domain intelligence — tracking U.S. government infrastructure registry'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.radar-view-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var view = this.dataset.view;
|
||||||
|
|
||||||
|
// Toggle button states
|
||||||
|
document.querySelectorAll('.radar-view-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Toggle views
|
||||||
|
document.getElementById('viewNewsfeed').classList.toggle('hidden', view !== 'newsfeed');
|
||||||
|
document.getElementById('viewGovtracker').classList.toggle('hidden', view !== 'govtracker');
|
||||||
|
|
||||||
|
// Update subtitle
|
||||||
|
document.getElementById('radarSubtitle').innerHTML = subtitles[view] || '';
|
||||||
|
|
||||||
|
// Lazy-load gov tracker data on first switch
|
||||||
|
if (view === 'govtracker' && !GovTracker.loaded) {
|
||||||
|
GovTracker.loadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════
|
||||||
|
// BOOT
|
||||||
|
// ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initViewToggle();
|
||||||
|
NewsFeed.init();
|
||||||
|
GovTracker.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Boot ──────────────────────────────────────
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<link rel="stylesheet" href="/css/section.css">
|
<link rel="stylesheet" href="/css/section.css">
|
||||||
<link rel="stylesheet" href="/css/radar.css?v=20260404">
|
<link rel="stylesheet" href="/css/radar.css?v=20260415">
|
||||||
<style>body{background:#0a0a0a;}</style>
|
<style>body{background:#0a0a0a;}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -41,10 +41,22 @@
|
||||||
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
||||||
<div class="section-header-label">TRANSMISSIONS // INCOMING SIGNALS</div>
|
<div class="section-header-label">TRANSMISSIONS // INCOMING SIGNALS</div>
|
||||||
<h1 class="section-header-title">RADAR</h1>
|
<h1 class="section-header-title">RADAR</h1>
|
||||||
<p class="section-header-sub">> Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs</p>
|
<p class="section-header-sub" id="radarSubtitle">> Live intelligence feed — intercepted from Hacker News, Reddit, and Lobste.rs</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ─── Top-Level View Toggle ─────────────────── -->
|
||||||
<div class="radar-container">
|
<div class="radar-container">
|
||||||
|
<div class="radar-view-toggle">
|
||||||
|
<button class="radar-view-btn active" data-view="newsfeed">
|
||||||
|
<span class="view-icon">◉</span> NEWS FEED
|
||||||
|
</button>
|
||||||
|
<button class="radar-view-btn" data-view="govtracker">
|
||||||
|
<span class="view-icon">◎</span> .GOV TRACKER
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ NEWS FEED VIEW ═══════════════════════ -->
|
||||||
|
<div class="radar-view" id="viewNewsfeed">
|
||||||
<div class="radar-controls">
|
<div class="radar-controls">
|
||||||
<div class="radar-filters">
|
<div class="radar-filters">
|
||||||
<button class="radar-filter active" data-source="all">ALL FEEDS</button>
|
<button class="radar-filter active" data-source="all">ALL FEEDS</button>
|
||||||
|
|
@ -72,6 +84,45 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ .GOV TRACKER VIEW ════════════════════ -->
|
||||||
|
<div class="radar-view hidden" id="viewGovtracker">
|
||||||
|
<div class="radar-controls">
|
||||||
|
<div class="radar-filters gov-range-filters">
|
||||||
|
<button class="gov-range active" data-range="all">ALL</button>
|
||||||
|
<button class="gov-range" data-range="24h">LAST 24H</button>
|
||||||
|
<button class="gov-range" data-range="3d">3 DAYS</button>
|
||||||
|
<button class="gov-range" data-range="7d">7 DAYS</button>
|
||||||
|
<button class="gov-range" data-range="14d">14 DAYS</button>
|
||||||
|
<button class="gov-range" data-range="30d">30 DAYS</button>
|
||||||
|
</div>
|
||||||
|
<div class="radar-actions">
|
||||||
|
<select class="gov-type-filter" id="govTypeFilter">
|
||||||
|
<option value="">ALL TYPES</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="radar-search" placeholder="SEARCH DOMAINS..." id="govSearch">
|
||||||
|
<button class="radar-refresh" id="govRefresh" title="Refresh data">↻ SYNC</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="radar-stats gov-stats">
|
||||||
|
<span class="radar-stat"><span class="stat-label">TOTAL DOMAINS:</span> <span id="govStatTotal">—</span></span>
|
||||||
|
<span class="radar-stat"><span class="stat-label">NEW (24H):</span> <span id="govStatNew24" class="stat-new">—</span></span>
|
||||||
|
<span class="radar-stat"><span class="stat-label">FEDERAL:</span> <span id="govStatFederal">—</span></span>
|
||||||
|
<span class="radar-stat"><span class="stat-label">STATE:</span> <span id="govStatState">—</span></span>
|
||||||
|
<span class="radar-stat"><span class="stat-label">LAST SYNC:</span> <span id="govStatSync">—</span></span>
|
||||||
|
<span class="radar-stat radar-live"><span class="live-dot gov-dot"></span> TRACKING</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gov-results-bar">
|
||||||
|
<span id="govResultCount">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="radar-feed" id="govFeed">
|
||||||
|
<div class="radar-loading">QUERYING .GOV REGISTRY...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-container">
|
<div class="footer-container">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
|
|
@ -87,6 +138,6 @@
|
||||||
<script src="/js/wallet-connect.js"></script>
|
<script src="/js/wallet-connect.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/clock.js"></script>
|
<script src="/js/clock.js"></script>
|
||||||
<script src="/js/radar.js?v=20260404"></script>
|
<script src="/js/radar.js?v=20260415"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue