phase3 #2
@@ -44,7 +44,7 @@ services:
|
|||||||
# Health check to ensure web service is running
|
# Health check to ensure web service is running
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"]
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|||||||
2204
docs/ai/PHASE3.md
Normal file
2204
docs/ai/PHASE3.md
Normal file
File diff suppressed because it is too large
Load Diff
1483
docs/ai/Phase4.md
Normal file
1483
docs/ai/Phase4.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,11 @@
|
|||||||
- Basic UI templates (dashboard, scans, login)
|
- Basic UI templates (dashboard, scans, login)
|
||||||
- Comprehensive error handling and logging
|
- Comprehensive error handling and logging
|
||||||
- 100 tests passing (1,825 lines of test code)
|
- 100 tests passing (1,825 lines of test code)
|
||||||
- ⏳ **Phase 3: Dashboard & Scheduling** - Next up (Weeks 5-6)
|
- ✅ **Phase 3: Dashboard & Scheduling** - Complete (2025-11-14)
|
||||||
- 📋 **Phase 4: Email & Comparisons** - Planned (Weeks 7-8)
|
- 📋 **Phase 4: Config Creator ** -Next up
|
||||||
- 📋 **Phase 5: CLI as API Client** - Planned (Week 9)
|
- 📋 **Phase 5: Email & Comparisons** - Planned (Weeks 7-8)
|
||||||
- 📋 **Phase 6: Advanced Features** - Planned (Weeks 10+)
|
- 📋 **Phase 6: CLI as API Client** - Planned (Week 9)
|
||||||
|
- 📋 **Phase 7: Advanced Features** - Planned (Weeks 10+)
|
||||||
|
|
||||||
## Vision & Goals
|
## Vision & Goals
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ marshmallow-sqlalchemy==0.29.0
|
|||||||
|
|
||||||
# Background Jobs & Scheduling
|
# Background Jobs & Scheduling
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
croniter==2.0.1
|
||||||
|
|
||||||
# Email Support (Phase 4)
|
# Email Support (Phase 4)
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
|
|||||||
319
tests/test_scan_comparison.py
Normal file
319
tests/test_scan_comparison.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for scan comparison functionality.
|
||||||
|
|
||||||
|
Tests scan comparison logic including port, service, and certificate comparisons,
|
||||||
|
as well as drift score calculation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from web.models import Scan, ScanSite, ScanIP, ScanPort
|
||||||
|
from web.models import ScanService as ScanServiceModel, ScanCertificate
|
||||||
|
from web.services.scan_service import ScanService
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanComparison:
|
||||||
|
"""Tests for scan comparison methods."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scan1_data(self, test_db, sample_config_file):
|
||||||
|
"""Create first scan with test data."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
|
||||||
|
|
||||||
|
# Get scan and add some test data
|
||||||
|
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
scan.status = 'completed'
|
||||||
|
|
||||||
|
# Create site
|
||||||
|
site = ScanSite(scan_id=scan.id, site_name='Test Site')
|
||||||
|
test_db.add(site)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create IP
|
||||||
|
ip = ScanIP(
|
||||||
|
scan_id=scan.id,
|
||||||
|
site_id=site.id,
|
||||||
|
ip_address='192.168.1.100',
|
||||||
|
ping_expected=True,
|
||||||
|
ping_actual=True
|
||||||
|
)
|
||||||
|
test_db.add(ip)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create ports
|
||||||
|
port1 = ScanPort(
|
||||||
|
scan_id=scan.id,
|
||||||
|
ip_id=ip.id,
|
||||||
|
port=80,
|
||||||
|
protocol='tcp',
|
||||||
|
state='open',
|
||||||
|
expected=True
|
||||||
|
)
|
||||||
|
port2 = ScanPort(
|
||||||
|
scan_id=scan.id,
|
||||||
|
ip_id=ip.id,
|
||||||
|
port=443,
|
||||||
|
protocol='tcp',
|
||||||
|
state='open',
|
||||||
|
expected=True
|
||||||
|
)
|
||||||
|
test_db.add(port1)
|
||||||
|
test_db.add(port2)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create service
|
||||||
|
svc1 = ScanServiceModel(
|
||||||
|
scan_id=scan.id,
|
||||||
|
port_id=port1.id,
|
||||||
|
service_name='http',
|
||||||
|
product='nginx',
|
||||||
|
version='1.18.0'
|
||||||
|
)
|
||||||
|
test_db.add(svc1)
|
||||||
|
|
||||||
|
test_db.commit()
|
||||||
|
return scan_id
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scan2_data(self, test_db, sample_config_file):
|
||||||
|
"""Create second scan with modified test data."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
|
||||||
|
|
||||||
|
# Get scan and add some test data
|
||||||
|
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
scan.status = 'completed'
|
||||||
|
|
||||||
|
# Create site
|
||||||
|
site = ScanSite(scan_id=scan.id, site_name='Test Site')
|
||||||
|
test_db.add(site)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create IP
|
||||||
|
ip = ScanIP(
|
||||||
|
scan_id=scan.id,
|
||||||
|
site_id=site.id,
|
||||||
|
ip_address='192.168.1.100',
|
||||||
|
ping_expected=True,
|
||||||
|
ping_actual=True
|
||||||
|
)
|
||||||
|
test_db.add(ip)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create ports (port 80 removed, 443 kept, 8080 added)
|
||||||
|
port2 = ScanPort(
|
||||||
|
scan_id=scan.id,
|
||||||
|
ip_id=ip.id,
|
||||||
|
port=443,
|
||||||
|
protocol='tcp',
|
||||||
|
state='open',
|
||||||
|
expected=True
|
||||||
|
)
|
||||||
|
port3 = ScanPort(
|
||||||
|
scan_id=scan.id,
|
||||||
|
ip_id=ip.id,
|
||||||
|
port=8080,
|
||||||
|
protocol='tcp',
|
||||||
|
state='open',
|
||||||
|
expected=False
|
||||||
|
)
|
||||||
|
test_db.add(port2)
|
||||||
|
test_db.add(port3)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
# Create service with updated version
|
||||||
|
svc2 = ScanServiceModel(
|
||||||
|
scan_id=scan.id,
|
||||||
|
port_id=port3.id,
|
||||||
|
service_name='http',
|
||||||
|
product='nginx',
|
||||||
|
version='1.20.0' # Version changed
|
||||||
|
)
|
||||||
|
test_db.add(svc2)
|
||||||
|
|
||||||
|
test_db.commit()
|
||||||
|
return scan_id
|
||||||
|
|
||||||
|
def test_compare_scans_basic(self, test_db, scan1_data, scan2_data):
|
||||||
|
"""Test basic scan comparison."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(scan1_data, scan2_data)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert 'scan1' in result
|
||||||
|
assert 'scan2' in result
|
||||||
|
assert 'ports' in result
|
||||||
|
assert 'services' in result
|
||||||
|
assert 'certificates' in result
|
||||||
|
assert 'drift_score' in result
|
||||||
|
|
||||||
|
# Verify scan metadata
|
||||||
|
assert result['scan1']['id'] == scan1_data
|
||||||
|
assert result['scan2']['id'] == scan2_data
|
||||||
|
|
||||||
|
def test_compare_scans_not_found(self, test_db):
|
||||||
|
"""Test comparison with nonexistent scan."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(999, 998)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_compare_ports(self, test_db, scan1_data, scan2_data):
|
||||||
|
"""Test port comparison logic."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(scan1_data, scan2_data)
|
||||||
|
|
||||||
|
# Scan1 has ports 80, 443
|
||||||
|
# Scan2 has ports 443, 8080
|
||||||
|
# Expected: added=[8080], removed=[80], unchanged=[443]
|
||||||
|
|
||||||
|
ports = result['ports']
|
||||||
|
assert len(ports['added']) == 1
|
||||||
|
assert len(ports['removed']) == 1
|
||||||
|
assert len(ports['unchanged']) == 1
|
||||||
|
|
||||||
|
# Check added port
|
||||||
|
added_port = ports['added'][0]
|
||||||
|
assert added_port['port'] == 8080
|
||||||
|
|
||||||
|
# Check removed port
|
||||||
|
removed_port = ports['removed'][0]
|
||||||
|
assert removed_port['port'] == 80
|
||||||
|
|
||||||
|
# Check unchanged port
|
||||||
|
unchanged_port = ports['unchanged'][0]
|
||||||
|
assert unchanged_port['port'] == 443
|
||||||
|
|
||||||
|
def test_compare_services(self, test_db, scan1_data, scan2_data):
|
||||||
|
"""Test service comparison logic."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(scan1_data, scan2_data)
|
||||||
|
|
||||||
|
services = result['services']
|
||||||
|
|
||||||
|
# Scan1 has nginx 1.18.0 on port 80
|
||||||
|
# Scan2 has nginx 1.20.0 on port 8080
|
||||||
|
# These are on different ports, so they should be added/removed, not changed
|
||||||
|
|
||||||
|
assert len(services['added']) >= 0
|
||||||
|
assert len(services['removed']) >= 0
|
||||||
|
|
||||||
|
def test_drift_score_calculation(self, test_db, scan1_data, scan2_data):
|
||||||
|
"""Test drift score calculation."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(scan1_data, scan2_data)
|
||||||
|
|
||||||
|
drift_score = result['drift_score']
|
||||||
|
|
||||||
|
# Drift score should be between 0.0 and 1.0
|
||||||
|
assert 0.0 <= drift_score <= 1.0
|
||||||
|
|
||||||
|
# Since we have changes (1 port added, 1 removed), drift should be > 0
|
||||||
|
assert drift_score > 0.0
|
||||||
|
|
||||||
|
def test_compare_identical_scans(self, test_db, scan1_data):
|
||||||
|
"""Test comparing a scan with itself (should have zero drift)."""
|
||||||
|
service = ScanService(test_db)
|
||||||
|
|
||||||
|
result = service.compare_scans(scan1_data, scan1_data)
|
||||||
|
|
||||||
|
# Comparing scan with itself should have zero drift
|
||||||
|
assert result['drift_score'] == 0.0
|
||||||
|
assert len(result['ports']['added']) == 0
|
||||||
|
assert len(result['ports']['removed']) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanComparisonAPI:
|
||||||
|
"""Tests for scan comparison API endpoint."""
|
||||||
|
|
||||||
|
def test_compare_scans_api(self, client, auth_headers, scan1_data, scan2_data):
|
||||||
|
"""Test scan comparison API endpoint."""
|
||||||
|
response = client.get(
|
||||||
|
f'/api/scans/{scan1_data}/compare/{scan2_data}',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert 'scan1' in data
|
||||||
|
assert 'scan2' in data
|
||||||
|
assert 'ports' in data
|
||||||
|
assert 'services' in data
|
||||||
|
assert 'drift_score' in data
|
||||||
|
|
||||||
|
def test_compare_scans_api_not_found(self, client, auth_headers):
|
||||||
|
"""Test comparison API with nonexistent scans."""
|
||||||
|
response = client.get(
|
||||||
|
'/api/scans/999/compare/998',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_compare_scans_api_requires_auth(self, client, scan1_data, scan2_data):
|
||||||
|
"""Test that comparison API requires authentication."""
|
||||||
|
response = client.get(f'/api/scans/{scan1_data}/compare/{scan2_data}')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoricalChartAPI:
|
||||||
|
"""Tests for historical scan chart API endpoint."""
|
||||||
|
|
||||||
|
def test_scan_history_api(self, client, auth_headers, scan1_data):
|
||||||
|
"""Test scan history API endpoint."""
|
||||||
|
response = client.get(
|
||||||
|
f'/api/stats/scan-history/{scan1_data}',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert 'scans' in data
|
||||||
|
assert 'labels' in data
|
||||||
|
assert 'port_counts' in data
|
||||||
|
assert 'config_file' in data
|
||||||
|
|
||||||
|
# Should include at least the scan we created
|
||||||
|
assert len(data['scans']) >= 1
|
||||||
|
|
||||||
|
def test_scan_history_api_not_found(self, client, auth_headers):
|
||||||
|
"""Test history API with nonexistent scan."""
|
||||||
|
response = client.get(
|
||||||
|
'/api/stats/scan-history/999',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_scan_history_api_limit(self, client, auth_headers, scan1_data):
|
||||||
|
"""Test scan history API with limit parameter."""
|
||||||
|
response = client.get(
|
||||||
|
f'/api/stats/scan-history/{scan1_data}?limit=5',
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Should respect limit
|
||||||
|
assert len(data['scans']) <= 5
|
||||||
|
|
||||||
|
def test_scan_history_api_requires_auth(self, client, scan1_data):
|
||||||
|
"""Test that history API requires authentication."""
|
||||||
|
response = client.get(f'/api/stats/scan-history/{scan1_data}')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
639
tests/test_schedule_api.py
Normal file
639
tests/test_schedule_api.py
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Schedule API endpoints.
|
||||||
|
|
||||||
|
Tests all schedule management endpoints including creating, listing,
|
||||||
|
updating, deleting schedules, and manually triggering scheduled scans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from web.models import Schedule, Scan
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_schedule(db, sample_config_file):
|
||||||
|
"""
|
||||||
|
Create a sample schedule in the database for testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session fixture
|
||||||
|
sample_config_file: Path to test config file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schedule model instance
|
||||||
|
"""
|
||||||
|
schedule = Schedule(
|
||||||
|
name='Daily Test Scan',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True,
|
||||||
|
last_run=None,
|
||||||
|
next_run=datetime(2025, 11, 15, 2, 0, 0),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(schedule)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(schedule)
|
||||||
|
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleAPIEndpoints:
|
||||||
|
"""Test suite for schedule API endpoints."""
|
||||||
|
|
||||||
|
def test_list_schedules_empty(self, client, db):
|
||||||
|
"""Test listing schedules when database is empty."""
|
||||||
|
response = client.get('/api/schedules')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['schedules'] == []
|
||||||
|
assert data['total'] == 0
|
||||||
|
assert data['page'] == 1
|
||||||
|
assert data['per_page'] == 20
|
||||||
|
|
||||||
|
def test_list_schedules_populated(self, client, db, sample_schedule):
|
||||||
|
"""Test listing schedules with existing data."""
|
||||||
|
response = client.get('/api/schedules')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['total'] == 1
|
||||||
|
assert len(data['schedules']) == 1
|
||||||
|
assert data['schedules'][0]['id'] == sample_schedule.id
|
||||||
|
assert data['schedules'][0]['name'] == sample_schedule.name
|
||||||
|
assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression
|
||||||
|
|
||||||
|
def test_list_schedules_pagination(self, client, db, sample_config_file):
|
||||||
|
"""Test schedule list pagination."""
|
||||||
|
# Create 25 schedules
|
||||||
|
for i in range(25):
|
||||||
|
schedule = Schedule(
|
||||||
|
name=f'Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Test page 1
|
||||||
|
response = client.get('/api/schedules?page=1&per_page=10')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['total'] == 25
|
||||||
|
assert len(data['schedules']) == 10
|
||||||
|
assert data['page'] == 1
|
||||||
|
assert data['per_page'] == 10
|
||||||
|
assert data['pages'] == 3
|
||||||
|
|
||||||
|
# Test page 2
|
||||||
|
response = client.get('/api/schedules?page=2&per_page=10')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert len(data['schedules']) == 10
|
||||||
|
assert data['page'] == 2
|
||||||
|
|
||||||
|
def test_list_schedules_filter_enabled(self, client, db, sample_config_file):
|
||||||
|
"""Test filtering schedules by enabled status."""
|
||||||
|
# Create enabled and disabled schedules
|
||||||
|
for i in range(3):
|
||||||
|
schedule = Schedule(
|
||||||
|
name=f'Enabled Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
|
||||||
|
for i in range(2):
|
||||||
|
schedule = Schedule(
|
||||||
|
name=f'Disabled Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 3 * * *',
|
||||||
|
enabled=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Filter by enabled=true
|
||||||
|
response = client.get('/api/schedules?enabled=true')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['total'] == 3
|
||||||
|
for schedule in data['schedules']:
|
||||||
|
assert schedule['enabled'] is True
|
||||||
|
|
||||||
|
# Filter by enabled=false
|
||||||
|
response = client.get('/api/schedules?enabled=false')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['total'] == 2
|
||||||
|
for schedule in data['schedules']:
|
||||||
|
assert schedule['enabled'] is False
|
||||||
|
|
||||||
|
def test_get_schedule(self, client, db, sample_schedule):
|
||||||
|
"""Test getting schedule details."""
|
||||||
|
response = client.get(f'/api/schedules/{sample_schedule.id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['id'] == sample_schedule.id
|
||||||
|
assert data['name'] == sample_schedule.name
|
||||||
|
assert data['config_file'] == sample_schedule.config_file
|
||||||
|
assert data['cron_expression'] == sample_schedule.cron_expression
|
||||||
|
assert data['enabled'] == sample_schedule.enabled
|
||||||
|
assert 'history' in data
|
||||||
|
|
||||||
|
def test_get_schedule_not_found(self, client, db):
|
||||||
|
"""Test getting non-existent schedule."""
|
||||||
|
response = client.get('/api/schedules/99999')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
assert 'not found' in data['error'].lower()
|
||||||
|
|
||||||
|
def test_create_schedule(self, client, db, sample_config_file):
|
||||||
|
"""Test creating a new schedule."""
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'New Test Schedule',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': '0 3 * * *',
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'schedule_id' in data
|
||||||
|
assert data['message'] == 'Schedule created successfully'
|
||||||
|
assert 'schedule' in data
|
||||||
|
|
||||||
|
# Verify schedule in database
|
||||||
|
schedule = db.query(Schedule).filter(Schedule.id == data['schedule_id']).first()
|
||||||
|
assert schedule is not None
|
||||||
|
assert schedule.name == schedule_data['name']
|
||||||
|
assert schedule.cron_expression == schedule_data['cron_expression']
|
||||||
|
|
||||||
|
def test_create_schedule_missing_fields(self, client, db):
|
||||||
|
"""Test creating schedule with missing required fields."""
|
||||||
|
# Missing cron_expression
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'Incomplete Schedule',
|
||||||
|
'config_file': '/app/configs/test.yaml'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
assert 'missing' in data['error'].lower()
|
||||||
|
|
||||||
|
def test_create_schedule_invalid_cron(self, client, db, sample_config_file):
|
||||||
|
"""Test creating schedule with invalid cron expression."""
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'Invalid Cron Schedule',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': 'invalid cron'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower()
|
||||||
|
|
||||||
|
def test_create_schedule_invalid_config(self, client, db):
|
||||||
|
"""Test creating schedule with non-existent config file."""
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'Invalid Config Schedule',
|
||||||
|
'config_file': '/nonexistent/config.yaml',
|
||||||
|
'cron_expression': '0 2 * * *'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
assert 'not found' in data['error'].lower()
|
||||||
|
|
||||||
|
def test_update_schedule(self, client, db, sample_schedule):
|
||||||
|
"""Test updating schedule fields."""
|
||||||
|
update_data = {
|
||||||
|
'name': 'Updated Schedule Name',
|
||||||
|
'cron_expression': '0 4 * * *'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{sample_schedule.id}',
|
||||||
|
data=json.dumps(update_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['message'] == 'Schedule updated successfully'
|
||||||
|
assert data['schedule']['name'] == update_data['name']
|
||||||
|
assert data['schedule']['cron_expression'] == update_data['cron_expression']
|
||||||
|
|
||||||
|
# Verify in database
|
||||||
|
db.refresh(sample_schedule)
|
||||||
|
assert sample_schedule.name == update_data['name']
|
||||||
|
assert sample_schedule.cron_expression == update_data['cron_expression']
|
||||||
|
|
||||||
|
def test_update_schedule_not_found(self, client, db):
|
||||||
|
"""Test updating non-existent schedule."""
|
||||||
|
update_data = {'name': 'New Name'}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
'/api/schedules/99999',
|
||||||
|
data=json.dumps(update_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_update_schedule_invalid_cron(self, client, db, sample_schedule):
|
||||||
|
"""Test updating schedule with invalid cron expression."""
|
||||||
|
update_data = {'cron_expression': 'invalid'}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{sample_schedule.id}',
|
||||||
|
data=json.dumps(update_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_update_schedule_toggle_enabled(self, client, db, sample_schedule):
|
||||||
|
"""Test enabling/disabling schedule."""
|
||||||
|
# Disable schedule
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{sample_schedule.id}',
|
||||||
|
data=json.dumps({'enabled': False}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['schedule']['enabled'] is False
|
||||||
|
|
||||||
|
# Enable schedule
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{sample_schedule.id}',
|
||||||
|
data=json.dumps({'enabled': True}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['schedule']['enabled'] is True
|
||||||
|
|
||||||
|
def test_update_schedule_no_data(self, client, db, sample_schedule):
|
||||||
|
"""Test updating schedule with no data."""
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{sample_schedule.id}',
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_delete_schedule(self, client, db, sample_schedule):
|
||||||
|
"""Test deleting a schedule."""
|
||||||
|
schedule_id = sample_schedule.id
|
||||||
|
|
||||||
|
response = client.delete(f'/api/schedules/{schedule_id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['message'] == 'Schedule deleted successfully'
|
||||||
|
assert data['schedule_id'] == schedule_id
|
||||||
|
|
||||||
|
# Verify deletion in database
|
||||||
|
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
assert schedule is None
|
||||||
|
|
||||||
|
def test_delete_schedule_not_found(self, client, db):
|
||||||
|
"""Test deleting non-existent schedule."""
|
||||||
|
response = client.delete('/api/schedules/99999')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file):
|
||||||
|
"""Test that deleting schedule preserves associated scans."""
|
||||||
|
# Create a scan associated with the schedule
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title='Test Scan',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=sample_schedule.id
|
||||||
|
)
|
||||||
|
db.add(scan)
|
||||||
|
db.commit()
|
||||||
|
scan_id = scan.id
|
||||||
|
|
||||||
|
# Delete schedule
|
||||||
|
response = client.delete(f'/api/schedules/{sample_schedule.id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify scan still exists
|
||||||
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
assert scan is not None
|
||||||
|
assert scan.schedule_id is None # Schedule ID becomes null
|
||||||
|
|
||||||
|
def test_trigger_schedule(self, client, db, sample_schedule):
|
||||||
|
"""Test manually triggering a scheduled scan."""
|
||||||
|
response = client.post(f'/api/schedules/{sample_schedule.id}/trigger')
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['message'] == 'Scan triggered successfully'
|
||||||
|
assert 'scan_id' in data
|
||||||
|
assert data['schedule_id'] == sample_schedule.id
|
||||||
|
|
||||||
|
# Verify scan was created
|
||||||
|
scan = db.query(Scan).filter(Scan.id == data['scan_id']).first()
|
||||||
|
assert scan is not None
|
||||||
|
assert scan.triggered_by == 'manual'
|
||||||
|
assert scan.schedule_id == sample_schedule.id
|
||||||
|
assert scan.config_file == sample_schedule.config_file
|
||||||
|
|
||||||
|
def test_trigger_schedule_not_found(self, client, db):
|
||||||
|
"""Test triggering non-existent schedule."""
|
||||||
|
response = client.post('/api/schedules/99999/trigger')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file):
|
||||||
|
"""Test getting schedule includes execution history."""
|
||||||
|
# Create some scans for this schedule
|
||||||
|
for i in range(5):
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title=f'Scheduled Scan {i}',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=sample_schedule.id
|
||||||
|
)
|
||||||
|
db.add(scan)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get(f'/api/schedules/{sample_schedule.id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'history' in data
|
||||||
|
assert len(data['history']) == 5
|
||||||
|
|
||||||
|
def test_schedule_workflow_integration(self, client, db, sample_config_file):
|
||||||
|
"""Test complete schedule workflow: create → update → trigger → delete."""
|
||||||
|
# 1. Create schedule
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'Integration Test Schedule',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': '0 2 * * *',
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
schedule_id = json.loads(response.data)['schedule_id']
|
||||||
|
|
||||||
|
# 2. Get schedule
|
||||||
|
response = client.get(f'/api/schedules/{schedule_id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 3. Update schedule
|
||||||
|
response = client.put(
|
||||||
|
f'/api/schedules/{schedule_id}',
|
||||||
|
data=json.dumps({'name': 'Updated Integration Test'}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 4. Trigger schedule
|
||||||
|
response = client.post(f'/api/schedules/{schedule_id}/trigger')
|
||||||
|
assert response.status_code == 201
|
||||||
|
scan_id = json.loads(response.data)['scan_id']
|
||||||
|
|
||||||
|
# 5. Verify scan was created
|
||||||
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
assert scan is not None
|
||||||
|
|
||||||
|
# 6. Delete schedule
|
||||||
|
response = client.delete(f'/api/schedules/{schedule_id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# 7. Verify schedule deleted
|
||||||
|
response = client.get(f'/api/schedules/{schedule_id}')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
# 8. Verify scan still exists
|
||||||
|
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
assert scan is not None
|
||||||
|
|
||||||
|
def test_list_schedules_ordering(self, client, db, sample_config_file):
|
||||||
|
"""Test that schedules are ordered by next_run time."""
|
||||||
|
# Create schedules with different next_run times
|
||||||
|
schedules = []
|
||||||
|
for i in range(3):
|
||||||
|
schedule = Schedule(
|
||||||
|
name=f'Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True,
|
||||||
|
next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
schedules.append(schedule)
|
||||||
|
|
||||||
|
# Create a disabled schedule (next_run is None)
|
||||||
|
disabled_schedule = Schedule(
|
||||||
|
name='Disabled Schedule',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 3 * * *',
|
||||||
|
enabled=False,
|
||||||
|
next_run=None,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(disabled_schedule)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/schedules')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
returned_schedules = data['schedules']
|
||||||
|
|
||||||
|
# Schedules with next_run should come before those without
|
||||||
|
# Within those with next_run, they should be ordered by time
|
||||||
|
assert returned_schedules[0]['id'] == schedules[0].id
|
||||||
|
assert returned_schedules[1]['id'] == schedules[1].id
|
||||||
|
assert returned_schedules[2]['id'] == schedules[2].id
|
||||||
|
assert returned_schedules[3]['id'] == disabled_schedule.id
|
||||||
|
|
||||||
|
def test_create_schedule_with_disabled(self, client, db, sample_config_file):
|
||||||
|
"""Test creating a disabled schedule."""
|
||||||
|
schedule_data = {
|
||||||
|
'name': 'Disabled Schedule',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': '0 2 * * *',
|
||||||
|
'enabled': False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['schedule']['enabled'] is False
|
||||||
|
assert data['schedule']['next_run'] is None # Disabled schedules have no next_run
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleAPIAuthentication:
|
||||||
|
"""Test suite for schedule API authentication."""
|
||||||
|
|
||||||
|
def test_schedules_require_authentication(self, app):
|
||||||
|
"""Test that all schedule endpoints require authentication."""
|
||||||
|
# Create unauthenticated client
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
('GET', '/api/schedules'),
|
||||||
|
('GET', '/api/schedules/1'),
|
||||||
|
('POST', '/api/schedules'),
|
||||||
|
('PUT', '/api/schedules/1'),
|
||||||
|
('DELETE', '/api/schedules/1'),
|
||||||
|
('POST', '/api/schedules/1/trigger')
|
||||||
|
]
|
||||||
|
|
||||||
|
for method, endpoint in endpoints:
|
||||||
|
if method == 'GET':
|
||||||
|
response = client.get(endpoint)
|
||||||
|
elif method == 'POST':
|
||||||
|
response = client.post(
|
||||||
|
endpoint,
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
elif method == 'PUT':
|
||||||
|
response = client.put(
|
||||||
|
endpoint,
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
elif method == 'DELETE':
|
||||||
|
response = client.delete(endpoint)
|
||||||
|
|
||||||
|
# Should redirect to login or return 401
|
||||||
|
assert response.status_code in [302, 401], \
|
||||||
|
f"{method} {endpoint} should require authentication"
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleAPICronValidation:
|
||||||
|
"""Test suite for cron expression validation."""
|
||||||
|
|
||||||
|
def test_valid_cron_expressions(self, client, db, sample_config_file):
|
||||||
|
"""Test various valid cron expressions."""
|
||||||
|
valid_expressions = [
|
||||||
|
'0 2 * * *', # Daily at 2am
|
||||||
|
'*/15 * * * *', # Every 15 minutes
|
||||||
|
'0 0 * * 0', # Weekly on Sunday
|
||||||
|
'0 0 1 * *', # Monthly on 1st
|
||||||
|
'0 */4 * * *', # Every 4 hours
|
||||||
|
]
|
||||||
|
|
||||||
|
for cron_expr in valid_expressions:
|
||||||
|
schedule_data = {
|
||||||
|
'name': f'Schedule for {cron_expr}',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': cron_expr
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, \
|
||||||
|
f"Valid cron expression '{cron_expr}' should be accepted"
|
||||||
|
|
||||||
|
def test_invalid_cron_expressions(self, client, db, sample_config_file):
|
||||||
|
"""Test various invalid cron expressions."""
|
||||||
|
invalid_expressions = [
|
||||||
|
'invalid',
|
||||||
|
'60 2 * * *', # Invalid minute
|
||||||
|
'0 25 * * *', # Invalid hour
|
||||||
|
'0 0 32 * *', # Invalid day
|
||||||
|
'0 0 * 13 *', # Invalid month
|
||||||
|
'0 0 * * 8', # Invalid day of week
|
||||||
|
]
|
||||||
|
|
||||||
|
for cron_expr in invalid_expressions:
|
||||||
|
schedule_data = {
|
||||||
|
'name': f'Schedule for {cron_expr}',
|
||||||
|
'config_file': sample_config_file,
|
||||||
|
'cron_expression': cron_expr
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/schedules',
|
||||||
|
data=json.dumps(schedule_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, \
|
||||||
|
f"Invalid cron expression '{cron_expr}' should be rejected"
|
||||||
671
tests/test_schedule_service.py
Normal file
671
tests/test_schedule_service.py
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for ScheduleService class.
|
||||||
|
|
||||||
|
Tests schedule lifecycle operations: create, get, list, update, delete, and
|
||||||
|
cron expression validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from web.models import Schedule, Scan
|
||||||
|
from web.services.schedule_service import ScheduleService
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceCreate:
|
||||||
|
"""Tests for creating schedules."""
|
||||||
|
|
||||||
|
def test_create_schedule_valid(self, test_db, sample_config_file):
|
||||||
|
"""Test creating a schedule with valid parameters."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Daily Scan',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify schedule created
|
||||||
|
assert schedule_id is not None
|
||||||
|
assert isinstance(schedule_id, int)
|
||||||
|
|
||||||
|
# Verify schedule in database
|
||||||
|
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
assert schedule is not None
|
||||||
|
assert schedule.name == 'Daily Scan'
|
||||||
|
assert schedule.config_file == sample_config_file
|
||||||
|
assert schedule.cron_expression == '0 2 * * *'
|
||||||
|
assert schedule.enabled is True
|
||||||
|
assert schedule.next_run is not None
|
||||||
|
assert schedule.last_run is None
|
||||||
|
|
||||||
|
def test_create_schedule_disabled(self, test_db, sample_config_file):
|
||||||
|
"""Test creating a disabled schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Disabled Scan',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 3 * * *',
|
||||||
|
enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
assert schedule.enabled is False
|
||||||
|
assert schedule.next_run is None
|
||||||
|
|
||||||
|
def test_create_schedule_invalid_cron(self, test_db, sample_config_file):
|
||||||
|
"""Test creating a schedule with invalid cron expression."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
|
service.create_schedule(
|
||||||
|
name='Invalid Schedule',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='invalid cron',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_schedule_nonexistent_config(self, test_db):
|
||||||
|
"""Test creating a schedule with nonexistent config file."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Config file not found"):
|
||||||
|
service.create_schedule(
|
||||||
|
name='Bad Config',
|
||||||
|
config_file='/nonexistent/config.yaml',
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_schedule_various_cron_expressions(self, test_db, sample_config_file):
|
||||||
|
"""Test creating schedules with various valid cron expressions."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
cron_expressions = [
|
||||||
|
'0 0 * * *', # Daily at midnight
|
||||||
|
'*/15 * * * *', # Every 15 minutes
|
||||||
|
'0 2 * * 0', # Weekly on Sunday at 2 AM
|
||||||
|
'0 0 1 * *', # Monthly on the 1st at midnight
|
||||||
|
'30 14 * * 1-5', # Weekdays at 2:30 PM
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, cron in enumerate(cron_expressions):
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name=f'Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression=cron,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
assert schedule_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceGet:
|
||||||
|
"""Tests for retrieving schedules."""
|
||||||
|
|
||||||
|
def test_get_schedule_not_found(self, test_db):
|
||||||
|
"""Test getting a nonexistent schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
|
service.get_schedule(999)
|
||||||
|
|
||||||
|
def test_get_schedule_found(self, test_db, sample_config_file):
|
||||||
|
"""Test getting an existing schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create a schedule
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test Schedule',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve it
|
||||||
|
result = service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['id'] == schedule_id
|
||||||
|
assert result['name'] == 'Test Schedule'
|
||||||
|
assert result['cron_expression'] == '0 2 * * *'
|
||||||
|
assert result['enabled'] is True
|
||||||
|
assert 'history' in result
|
||||||
|
assert isinstance(result['history'], list)
|
||||||
|
|
||||||
|
def test_get_schedule_with_history(self, test_db, sample_config_file):
|
||||||
|
"""Test getting schedule includes execution history."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create schedule
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test Schedule',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create associated scans
|
||||||
|
for i in range(3):
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title=f'Scan {i}',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=schedule_id
|
||||||
|
)
|
||||||
|
test_db.add(scan)
|
||||||
|
test_db.commit()
|
||||||
|
|
||||||
|
# Get schedule
|
||||||
|
result = service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
assert len(result['history']) == 3
|
||||||
|
assert result['history'][0]['title'] == 'Scan 0' # Most recent first
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceList:
|
||||||
|
"""Tests for listing schedules."""
|
||||||
|
|
||||||
|
def test_list_schedules_empty(self, test_db):
|
||||||
|
"""Test listing schedules when database is empty."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
result = service.list_schedules(page=1, per_page=20)
|
||||||
|
|
||||||
|
assert result['total'] == 0
|
||||||
|
assert len(result['schedules']) == 0
|
||||||
|
assert result['page'] == 1
|
||||||
|
assert result['per_page'] == 20
|
||||||
|
|
||||||
|
def test_list_schedules_populated(self, test_db, sample_config_file):
|
||||||
|
"""Test listing schedules with data."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create multiple schedules
|
||||||
|
for i in range(5):
|
||||||
|
service.create_schedule(
|
||||||
|
name=f'Schedule {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.list_schedules(page=1, per_page=20)
|
||||||
|
|
||||||
|
assert result['total'] == 5
|
||||||
|
assert len(result['schedules']) == 5
|
||||||
|
assert all('name' in s for s in result['schedules'])
|
||||||
|
|
||||||
|
def test_list_schedules_pagination(self, test_db, sample_config_file):
|
||||||
|
"""Test schedule pagination."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create 25 schedules
|
||||||
|
for i in range(25):
|
||||||
|
service.create_schedule(
|
||||||
|
name=f'Schedule {i:02d}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
result_page1 = service.list_schedules(page=1, per_page=10)
|
||||||
|
assert len(result_page1['schedules']) == 10
|
||||||
|
assert result_page1['total'] == 25
|
||||||
|
assert result_page1['pages'] == 3
|
||||||
|
|
||||||
|
# Get second page
|
||||||
|
result_page2 = service.list_schedules(page=2, per_page=10)
|
||||||
|
assert len(result_page2['schedules']) == 10
|
||||||
|
|
||||||
|
# Get third page
|
||||||
|
result_page3 = service.list_schedules(page=3, per_page=10)
|
||||||
|
assert len(result_page3['schedules']) == 5
|
||||||
|
|
||||||
|
def test_list_schedules_filter_enabled(self, test_db, sample_config_file):
|
||||||
|
"""Test filtering schedules by enabled status."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create enabled and disabled schedules
|
||||||
|
for i in range(3):
|
||||||
|
service.create_schedule(
|
||||||
|
name=f'Enabled {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
for i in range(2):
|
||||||
|
service.create_schedule(
|
||||||
|
name=f'Disabled {i}',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter enabled only
|
||||||
|
result_enabled = service.list_schedules(enabled_filter=True)
|
||||||
|
assert result_enabled['total'] == 3
|
||||||
|
|
||||||
|
# Filter disabled only
|
||||||
|
result_disabled = service.list_schedules(enabled_filter=False)
|
||||||
|
assert result_disabled['total'] == 2
|
||||||
|
|
||||||
|
# No filter
|
||||||
|
result_all = service.list_schedules(enabled_filter=None)
|
||||||
|
assert result_all['total'] == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceUpdate:
|
||||||
|
"""Tests for updating schedules."""
|
||||||
|
|
||||||
|
def test_update_schedule_name(self, test_db, sample_config_file):
|
||||||
|
"""Test updating schedule name."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Old Name',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.update_schedule(schedule_id, name='New Name')
|
||||||
|
|
||||||
|
assert result['name'] == 'New Name'
|
||||||
|
assert result['cron_expression'] == '0 2 * * *'
|
||||||
|
|
||||||
|
def test_update_schedule_cron(self, test_db, sample_config_file):
|
||||||
|
"""Test updating cron expression recalculates next_run."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
original = service.get_schedule(schedule_id)
|
||||||
|
original_next_run = original['next_run']
|
||||||
|
|
||||||
|
# Update cron expression
|
||||||
|
result = service.update_schedule(
|
||||||
|
schedule_id,
|
||||||
|
cron_expression='0 3 * * *'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Next run should be recalculated
|
||||||
|
assert result['cron_expression'] == '0 3 * * *'
|
||||||
|
assert result['next_run'] != original_next_run
|
||||||
|
|
||||||
|
def test_update_schedule_invalid_cron(self, test_db, sample_config_file):
|
||||||
|
"""Test updating with invalid cron expression fails."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
|
service.update_schedule(schedule_id, cron_expression='invalid')
|
||||||
|
|
||||||
|
def test_update_schedule_not_found(self, test_db):
|
||||||
|
"""Test updating nonexistent schedule fails."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
|
service.update_schedule(999, name='New Name')
|
||||||
|
|
||||||
|
def test_update_schedule_invalid_config_file(self, test_db, sample_config_file):
|
||||||
|
"""Test updating with nonexistent config file fails."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Config file not found"):
|
||||||
|
service.update_schedule(schedule_id, config_file='/nonexistent.yaml')
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceDelete:
|
||||||
|
"""Tests for deleting schedules."""
|
||||||
|
|
||||||
|
def test_delete_schedule(self, test_db, sample_config_file):
|
||||||
|
"""Test deleting a schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='To Delete',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify exists
|
||||||
|
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
result = service.delete_schedule(schedule_id)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
|
||||||
|
|
||||||
|
def test_delete_schedule_not_found(self, test_db):
|
||||||
|
"""Test deleting nonexistent schedule fails."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
|
service.delete_schedule(999)
|
||||||
|
|
||||||
|
def test_delete_schedule_preserves_scans(self, test_db, sample_config_file):
|
||||||
|
"""Test that deleting schedule preserves associated scans."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Create schedule
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create associated scan
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title='Test Scan',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=schedule_id
|
||||||
|
)
|
||||||
|
test_db.add(scan)
|
||||||
|
test_db.commit()
|
||||||
|
scan_id = scan.id
|
||||||
|
|
||||||
|
# Delete schedule
|
||||||
|
service.delete_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Verify scan still exists (schedule_id becomes null)
|
||||||
|
remaining_scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
assert remaining_scan is not None
|
||||||
|
assert remaining_scan.schedule_id is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceToggle:
|
||||||
|
"""Tests for toggling schedule enabled status."""
|
||||||
|
|
||||||
|
def test_toggle_enabled_to_disabled(self, test_db, sample_config_file):
|
||||||
|
"""Test disabling an enabled schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.toggle_enabled(schedule_id, enabled=False)
|
||||||
|
|
||||||
|
assert result['enabled'] is False
|
||||||
|
assert result['next_run'] is None
|
||||||
|
|
||||||
|
def test_toggle_disabled_to_enabled(self, test_db, sample_config_file):
|
||||||
|
"""Test enabling a disabled schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.toggle_enabled(schedule_id, enabled=True)
|
||||||
|
|
||||||
|
assert result['enabled'] is True
|
||||||
|
assert result['next_run'] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleServiceRunTimes:
|
||||||
|
"""Tests for updating run times."""
|
||||||
|
|
||||||
|
def test_update_run_times(self, test_db, sample_config_file):
|
||||||
|
"""Test updating last_run and next_run."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
last_run = datetime.utcnow()
|
||||||
|
next_run = datetime.utcnow() + timedelta(days=1)
|
||||||
|
|
||||||
|
result = service.update_run_times(schedule_id, last_run, next_run)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
schedule = service.get_schedule(schedule_id)
|
||||||
|
assert schedule['last_run'] is not None
|
||||||
|
assert schedule['next_run'] is not None
|
||||||
|
|
||||||
|
def test_update_run_times_not_found(self, test_db):
|
||||||
|
"""Test updating run times for nonexistent schedule."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Schedule .* not found"):
|
||||||
|
service.update_run_times(
|
||||||
|
999,
|
||||||
|
datetime.utcnow(),
|
||||||
|
datetime.utcnow() + timedelta(days=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronValidation:
|
||||||
|
"""Tests for cron expression validation."""
|
||||||
|
|
||||||
|
def test_validate_cron_valid_expressions(self, test_db):
|
||||||
|
"""Test validating various valid cron expressions."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
valid_expressions = [
|
||||||
|
'0 0 * * *', # Daily at midnight
|
||||||
|
'*/15 * * * *', # Every 15 minutes
|
||||||
|
'0 2 * * 0', # Weekly on Sunday
|
||||||
|
'0 0 1 * *', # Monthly
|
||||||
|
'30 14 * * 1-5', # Weekdays
|
||||||
|
'0 */4 * * *', # Every 4 hours
|
||||||
|
]
|
||||||
|
|
||||||
|
for expr in valid_expressions:
|
||||||
|
is_valid, error = service.validate_cron_expression(expr)
|
||||||
|
assert is_valid is True, f"Expression '{expr}' should be valid"
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_validate_cron_invalid_expressions(self, test_db):
|
||||||
|
"""Test validating invalid cron expressions."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
invalid_expressions = [
|
||||||
|
'invalid',
|
||||||
|
'60 0 * * *', # Invalid minute (0-59)
|
||||||
|
'0 24 * * *', # Invalid hour (0-23)
|
||||||
|
'0 0 32 * *', # Invalid day (1-31)
|
||||||
|
'0 0 * 13 *', # Invalid month (1-12)
|
||||||
|
'0 0 * * 7', # Invalid weekday (0-6)
|
||||||
|
]
|
||||||
|
|
||||||
|
for expr in invalid_expressions:
|
||||||
|
is_valid, error = service.validate_cron_expression(expr)
|
||||||
|
assert is_valid is False, f"Expression '{expr}' should be invalid"
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextRunCalculation:
|
||||||
|
"""Tests for next run time calculation."""
|
||||||
|
|
||||||
|
def test_calculate_next_run(self, test_db):
|
||||||
|
"""Test calculating next run time."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
# Daily at 2 AM
|
||||||
|
next_run = service.calculate_next_run('0 2 * * *')
|
||||||
|
|
||||||
|
assert next_run is not None
|
||||||
|
assert isinstance(next_run, datetime)
|
||||||
|
assert next_run > datetime.utcnow()
|
||||||
|
|
||||||
|
def test_calculate_next_run_from_time(self, test_db):
|
||||||
|
"""Test calculating next run from specific time."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
base_time = datetime(2025, 1, 1, 0, 0, 0)
|
||||||
|
next_run = service.calculate_next_run('0 2 * * *', from_time=base_time)
|
||||||
|
|
||||||
|
# Should be 2 AM on same day
|
||||||
|
assert next_run.hour == 2
|
||||||
|
assert next_run.minute == 0
|
||||||
|
|
||||||
|
def test_calculate_next_run_invalid_cron(self, test_db):
|
||||||
|
"""Test calculating next run with invalid cron raises error."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid cron expression"):
|
||||||
|
service.calculate_next_run('invalid cron')
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleHistory:
|
||||||
|
"""Tests for schedule execution history."""
|
||||||
|
|
||||||
|
def test_get_schedule_history_empty(self, test_db, sample_config_file):
|
||||||
|
"""Test getting history for schedule with no executions."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
history = service.get_schedule_history(schedule_id)
|
||||||
|
assert len(history) == 0
|
||||||
|
|
||||||
|
def test_get_schedule_history_with_scans(self, test_db, sample_config_file):
|
||||||
|
"""Test getting history with multiple scans."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 15 scans
|
||||||
|
for i in range(15):
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title=f'Scan {i}',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=schedule_id
|
||||||
|
)
|
||||||
|
test_db.add(scan)
|
||||||
|
test_db.commit()
|
||||||
|
|
||||||
|
# Get history (default limit 10)
|
||||||
|
history = service.get_schedule_history(schedule_id, limit=10)
|
||||||
|
assert len(history) == 10
|
||||||
|
assert history[0]['title'] == 'Scan 0' # Most recent first
|
||||||
|
|
||||||
|
def test_get_schedule_history_custom_limit(self, test_db, sample_config_file):
|
||||||
|
"""Test getting history with custom limit."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 10 scans
|
||||||
|
for i in range(10):
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow() - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
title=f'Scan {i}',
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=schedule_id
|
||||||
|
)
|
||||||
|
test_db.add(scan)
|
||||||
|
test_db.commit()
|
||||||
|
|
||||||
|
# Get only 5
|
||||||
|
history = service.get_schedule_history(schedule_id, limit=5)
|
||||||
|
assert len(history) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleSerialization:
|
||||||
|
"""Tests for schedule serialization."""
|
||||||
|
|
||||||
|
def test_schedule_to_dict(self, test_db, sample_config_file):
|
||||||
|
"""Test converting schedule to dictionary."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test Schedule',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Verify all required fields
|
||||||
|
assert 'id' in result
|
||||||
|
assert 'name' in result
|
||||||
|
assert 'config_file' in result
|
||||||
|
assert 'cron_expression' in result
|
||||||
|
assert 'enabled' in result
|
||||||
|
assert 'last_run' in result
|
||||||
|
assert 'next_run' in result
|
||||||
|
assert 'next_run_relative' in result
|
||||||
|
assert 'created_at' in result
|
||||||
|
assert 'updated_at' in result
|
||||||
|
assert 'history' in result
|
||||||
|
|
||||||
|
def test_schedule_relative_time_formatting(self, test_db, sample_config_file):
|
||||||
|
"""Test relative time formatting in schedule dict."""
|
||||||
|
service = ScheduleService(test_db)
|
||||||
|
|
||||||
|
schedule_id = service.create_schedule(
|
||||||
|
name='Test',
|
||||||
|
config_file=sample_config_file,
|
||||||
|
cron_expression='0 2 * * *',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Should have relative time for next_run
|
||||||
|
assert result['next_run_relative'] is not None
|
||||||
|
assert isinstance(result['next_run_relative'], str)
|
||||||
|
assert 'in' in result['next_run_relative'].lower()
|
||||||
325
tests/test_stats_api.py
Normal file
325
tests/test_stats_api.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
Tests for stats API endpoints.
|
||||||
|
|
||||||
|
Tests dashboard statistics and trending data endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from web.models import Scan
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatsAPI:
|
||||||
|
"""Test suite for stats API endpoints."""
|
||||||
|
|
||||||
|
def test_scan_trend_default_30_days(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend endpoint with default 30 days."""
|
||||||
|
# Create test scans over multiple days
|
||||||
|
today = datetime.utcnow()
|
||||||
|
for i in range(5):
|
||||||
|
scan_date = today - timedelta(days=i)
|
||||||
|
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=scan_date,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request trend data
|
||||||
|
response = client.get('/api/stats/scan-trend', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'labels' in data
|
||||||
|
assert 'values' in data
|
||||||
|
assert 'start_date' in data
|
||||||
|
assert 'end_date' in data
|
||||||
|
assert 'total_scans' in data
|
||||||
|
|
||||||
|
# Should have 30 days of data
|
||||||
|
assert len(data['labels']) == 30
|
||||||
|
assert len(data['values']) == 30
|
||||||
|
|
||||||
|
# Total scans should match (1+2+3+4+5 = 15)
|
||||||
|
assert data['total_scans'] == 15
|
||||||
|
|
||||||
|
# Values should be non-negative integers
|
||||||
|
assert all(isinstance(v, int) for v in data['values'])
|
||||||
|
assert all(v >= 0 for v in data['values'])
|
||||||
|
|
||||||
|
def test_scan_trend_custom_days(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend endpoint with custom number of days."""
|
||||||
|
# Create test scans
|
||||||
|
today = datetime.utcnow()
|
||||||
|
for i in range(10):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request 7 days of data
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data['labels']) == 7
|
||||||
|
assert len(data['values']) == 7
|
||||||
|
assert data['total_scans'] == 7
|
||||||
|
|
||||||
|
def test_scan_trend_max_days_365(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint accepts maximum 365 days."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=365', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data['labels']) == 365
|
||||||
|
|
||||||
|
def test_scan_trend_rejects_days_over_365(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint rejects more than 365 days."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=366', headers=auth_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
assert '365' in data['error']
|
||||||
|
|
||||||
|
def test_scan_trend_rejects_days_less_than_1(self, client, auth_headers):
|
||||||
|
"""Test scan trend endpoint rejects days less than 1."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=0', headers=auth_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_scan_trend_fills_missing_days_with_zero(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend fills days with no scans as zero."""
|
||||||
|
# Create scans only on specific days
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create scan 5 days ago
|
||||||
|
scan1 = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=5),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan1)
|
||||||
|
|
||||||
|
# Create scan 10 days ago
|
||||||
|
scan2 = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=10),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan2)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request 15 days
|
||||||
|
response = client.get('/api/stats/scan-trend?days=15', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Should have 15 days of data
|
||||||
|
assert len(data['values']) == 15
|
||||||
|
|
||||||
|
# Most days should be zero
|
||||||
|
zero_days = sum(1 for v in data['values'] if v == 0)
|
||||||
|
assert zero_days >= 13 # At least 13 days with no scans
|
||||||
|
|
||||||
|
def test_scan_trend_requires_authentication(self, client):
|
||||||
|
"""Test scan trend endpoint requires authentication."""
|
||||||
|
response = client.get('/api/stats/scan-trend')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_summary_endpoint(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary statistics endpoint."""
|
||||||
|
# Create test scans with different statuses
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# 5 completed scans
|
||||||
|
for i in range(5):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# 2 failed scans
|
||||||
|
for i in range(2):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='failed',
|
||||||
|
duration=5.0
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# 1 running scan
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today,
|
||||||
|
status='running',
|
||||||
|
duration=None
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Request summary
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'total_scans' in data
|
||||||
|
assert 'completed_scans' in data
|
||||||
|
assert 'failed_scans' in data
|
||||||
|
assert 'running_scans' in data
|
||||||
|
assert 'scans_today' in data
|
||||||
|
assert 'scans_this_week' in data
|
||||||
|
|
||||||
|
# Verify counts
|
||||||
|
assert data['total_scans'] == 8
|
||||||
|
assert data['completed_scans'] == 5
|
||||||
|
assert data['failed_scans'] == 2
|
||||||
|
assert data['running_scans'] == 1
|
||||||
|
assert data['scans_today'] >= 1
|
||||||
|
assert data['scans_this_week'] >= 1
|
||||||
|
|
||||||
|
def test_summary_with_no_scans(self, client, auth_headers):
|
||||||
|
"""Test summary endpoint with no scans."""
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['total_scans'] == 0
|
||||||
|
assert data['completed_scans'] == 0
|
||||||
|
assert data['failed_scans'] == 0
|
||||||
|
assert data['running_scans'] == 0
|
||||||
|
assert data['scans_today'] == 0
|
||||||
|
assert data['scans_this_week'] == 0
|
||||||
|
|
||||||
|
def test_summary_scans_today(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary counts scans today correctly."""
|
||||||
|
today = datetime.utcnow()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
|
||||||
|
# Create 3 scans today
|
||||||
|
for i in range(3):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
# Create 2 scans yesterday
|
||||||
|
for i in range(2):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=yesterday,
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['scans_today'] == 3
|
||||||
|
assert data['scans_this_week'] >= 3
|
||||||
|
|
||||||
|
def test_summary_scans_this_week(self, client, auth_headers, db_session):
|
||||||
|
"""Test summary counts scans this week correctly."""
|
||||||
|
today = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create scans over the last 10 days
|
||||||
|
for i in range(10):
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=today - timedelta(days=i),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/summary', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
# Last 7 days (0-6) = 7 scans
|
||||||
|
assert data['scans_this_week'] == 7
|
||||||
|
|
||||||
|
def test_summary_requires_authentication(self, client):
|
||||||
|
"""Test summary endpoint requires authentication."""
|
||||||
|
response = client.get('/api/stats/summary')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_scan_trend_date_format(self, client, auth_headers, db_session):
|
||||||
|
"""Test scan trend returns dates in correct format."""
|
||||||
|
# Create a scan
|
||||||
|
scan = Scan(
|
||||||
|
config_file='/app/configs/test.yaml',
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='completed',
|
||||||
|
duration=10.5
|
||||||
|
)
|
||||||
|
db_session.add(scan)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Check date format (YYYY-MM-DD)
|
||||||
|
for label in data['labels']:
|
||||||
|
assert len(label) == 10
|
||||||
|
assert label[4] == '-'
|
||||||
|
assert label[7] == '-'
|
||||||
|
# Try parsing to ensure valid date
|
||||||
|
datetime.strptime(label, '%Y-%m-%d')
|
||||||
|
|
||||||
|
def test_scan_trend_consecutive_dates(self, client, auth_headers):
|
||||||
|
"""Test scan trend returns consecutive dates."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
labels = data['labels']
|
||||||
|
|
||||||
|
# Convert to datetime objects
|
||||||
|
dates = [datetime.strptime(label, '%Y-%m-%d') for label in labels]
|
||||||
|
|
||||||
|
# Check dates are consecutive
|
||||||
|
for i in range(len(dates) - 1):
|
||||||
|
diff = dates[i + 1] - dates[i]
|
||||||
|
assert diff.days == 1, f"Dates not consecutive: {dates[i]} to {dates[i+1]}"
|
||||||
|
|
||||||
|
def test_scan_trend_ends_with_today(self, client, auth_headers):
|
||||||
|
"""Test scan trend ends with today's date."""
|
||||||
|
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Last date should be today
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
last_date = datetime.strptime(data['labels'][-1], '%Y-%m-%d').date()
|
||||||
|
assert last_date == today
|
||||||
@@ -165,10 +165,12 @@ def trigger_scan():
|
|||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Config file validation error
|
# Config file validation error
|
||||||
logger.warning(f"Invalid config file: {str(e)}")
|
error_message = str(e)
|
||||||
|
logger.warning(f"Invalid config file: {error_message}")
|
||||||
|
logger.warning(f"Request data: config_file='{config_file}'")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Invalid request',
|
'error': 'Invalid request',
|
||||||
'message': str(e)
|
'message': error_message
|
||||||
}), 400
|
}), 400
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Database error triggering scan: {str(e)}")
|
logger.error(f"Database error triggering scan: {str(e)}")
|
||||||
@@ -276,20 +278,48 @@ def compare_scans(scan_id1, scan_id2):
|
|||||||
"""
|
"""
|
||||||
Compare two scans and show differences.
|
Compare two scans and show differences.
|
||||||
|
|
||||||
|
Compares ports, services, and certificates between two scans,
|
||||||
|
highlighting added, removed, and changed items.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id1: First scan ID
|
scan_id1: First (older) scan ID
|
||||||
scan_id2: Second scan ID
|
scan_id2: Second (newer) scan ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with comparison results
|
JSON response with comparison results including:
|
||||||
|
- scan1, scan2: Metadata for both scans
|
||||||
|
- ports: Added, removed, and unchanged ports
|
||||||
|
- services: Added, removed, and changed services
|
||||||
|
- certificates: Added, removed, and changed certificates
|
||||||
|
- drift_score: Overall drift metric (0.0-1.0)
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 4
|
try:
|
||||||
return jsonify({
|
# Compare scans using service
|
||||||
'scan_id1': scan_id1,
|
scan_service = ScanService(current_app.db_session)
|
||||||
'scan_id2': scan_id2,
|
comparison = scan_service.compare_scans(scan_id1, scan_id2)
|
||||||
'diff': {},
|
|
||||||
'message': 'Scan comparison endpoint - to be implemented in Phase 4'
|
if not comparison:
|
||||||
})
|
logger.warning(f"Scan comparison failed: one or both scans not found ({scan_id1}, {scan_id2})")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Not found',
|
||||||
|
'message': 'One or both scans not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
logger.info(f"Compared scans {scan_id1} and {scan_id2}: drift_score={comparison['drift_score']}")
|
||||||
|
return jsonify(comparison), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error comparing scans {scan_id1} and {scan_id2}: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Database error',
|
||||||
|
'message': 'Failed to compare scans'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error comparing scans {scan_id1} and {scan_id2}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ Handles endpoints for managing scheduled scans including CRUD operations
|
|||||||
and manual triggering.
|
and manual triggering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, current_app
|
||||||
|
|
||||||
from web.auth.decorators import api_auth_required
|
from web.auth.decorators import api_auth_required
|
||||||
|
from web.services.schedule_service import ScheduleService
|
||||||
|
from web.services.scan_service import ScanService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint('schedules', __name__)
|
bp = Blueprint('schedules', __name__)
|
||||||
|
|
||||||
@@ -16,16 +22,36 @@ bp = Blueprint('schedules', __name__)
|
|||||||
@api_auth_required
|
@api_auth_required
|
||||||
def list_schedules():
|
def list_schedules():
|
||||||
"""
|
"""
|
||||||
List all schedules.
|
List all schedules with pagination and filtering.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
page: Page number (default: 1)
|
||||||
|
per_page: Items per page (default: 20)
|
||||||
|
enabled: Filter by enabled status (true/false)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with schedules list
|
JSON response with paginated schedules list
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
return jsonify({
|
# Parse query parameters
|
||||||
'schedules': [],
|
page = request.args.get('page', 1, type=int)
|
||||||
'message': 'Schedules list endpoint - to be implemented in Phase 3'
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
})
|
enabled_str = request.args.get('enabled', type=str)
|
||||||
|
|
||||||
|
# Parse enabled filter
|
||||||
|
enabled_filter = None
|
||||||
|
if enabled_str is not None:
|
||||||
|
enabled_filter = enabled_str.lower() == 'true'
|
||||||
|
|
||||||
|
# Get schedules
|
||||||
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
|
result = schedule_service.list_schedules(page, per_page, enabled_filter)
|
||||||
|
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing schedules: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:schedule_id>', methods=['GET'])
|
@bp.route('/<int:schedule_id>', methods=['GET'])
|
||||||
@@ -38,13 +64,20 @@ def get_schedule(schedule_id):
|
|||||||
schedule_id: Schedule ID
|
schedule_id: Schedule ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with schedule details
|
JSON response with schedule details including execution history
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
return jsonify({
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
'schedule_id': schedule_id,
|
schedule = schedule_service.get_schedule(schedule_id)
|
||||||
'message': 'Schedule detail endpoint - to be implemented in Phase 3'
|
|
||||||
})
|
return jsonify(schedule), 200
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Schedule not found
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('', methods=['POST'])
|
@bp.route('', methods=['POST'])
|
||||||
@@ -54,22 +87,60 @@ def create_schedule():
|
|||||||
Create a new schedule.
|
Create a new schedule.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
name: Schedule name
|
name: Schedule name (required)
|
||||||
config_file: Path to YAML config
|
config_file: Path to YAML config (required)
|
||||||
cron_expression: Cron-like schedule expression
|
cron_expression: Cron expression (required, e.g., '0 2 * * *')
|
||||||
|
enabled: Whether schedule is active (optional, default: true)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with created schedule ID
|
JSON response with created schedule ID
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
return jsonify({
|
# Validate required fields
|
||||||
'schedule_id': None,
|
required = ['name', 'config_file', 'cron_expression']
|
||||||
'status': 'not_implemented',
|
missing = [field for field in required if field not in data]
|
||||||
'message': 'Schedule creation endpoint - to be implemented in Phase 3',
|
if missing:
|
||||||
'data': data
|
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
|
||||||
}), 501
|
|
||||||
|
# Create schedule
|
||||||
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
|
schedule_id = schedule_service.create_schedule(
|
||||||
|
name=data['name'],
|
||||||
|
config_file=data['config_file'],
|
||||||
|
cron_expression=data['cron_expression'],
|
||||||
|
enabled=data.get('enabled', True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the created schedule
|
||||||
|
schedule = schedule_service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Add to APScheduler if enabled
|
||||||
|
if schedule['enabled'] and hasattr(current_app, 'scheduler'):
|
||||||
|
try:
|
||||||
|
current_app.scheduler.add_scheduled_scan(
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
config_file=schedule['config_file'],
|
||||||
|
cron_expression=schedule['cron_expression']
|
||||||
|
)
|
||||||
|
logger.info(f"Schedule {schedule_id} added to APScheduler")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add schedule {schedule_id} to APScheduler: {str(e)}")
|
||||||
|
# Continue anyway - schedule is created in DB
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'schedule_id': schedule_id,
|
||||||
|
'message': 'Schedule created successfully',
|
||||||
|
'schedule': schedule
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Validation error
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating schedule: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:schedule_id>', methods=['PUT'])
|
@bp.route('/<int:schedule_id>', methods=['PUT'])
|
||||||
@@ -84,21 +155,73 @@ def update_schedule(schedule_id):
|
|||||||
Request body:
|
Request body:
|
||||||
name: Schedule name (optional)
|
name: Schedule name (optional)
|
||||||
config_file: Path to YAML config (optional)
|
config_file: Path to YAML config (optional)
|
||||||
cron_expression: Cron-like schedule expression (optional)
|
cron_expression: Cron expression (optional)
|
||||||
enabled: Whether schedule is active (optional)
|
enabled: Whether schedule is active (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with update status
|
JSON response with updated schedule
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
return jsonify({
|
if not data:
|
||||||
'schedule_id': schedule_id,
|
return jsonify({'error': 'No update data provided'}), 400
|
||||||
'status': 'not_implemented',
|
|
||||||
'message': 'Schedule update endpoint - to be implemented in Phase 3',
|
# Update schedule
|
||||||
'data': data
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
}), 501
|
|
||||||
|
# Store old state to check if scheduler update needed
|
||||||
|
old_schedule = schedule_service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Perform update
|
||||||
|
updated_schedule = schedule_service.update_schedule(schedule_id, **data)
|
||||||
|
|
||||||
|
# Update in APScheduler if needed
|
||||||
|
if hasattr(current_app, 'scheduler'):
|
||||||
|
try:
|
||||||
|
# If cron expression or config changed, or enabled status changed
|
||||||
|
cron_changed = 'cron_expression' in data
|
||||||
|
config_changed = 'config_file' in data
|
||||||
|
enabled_changed = 'enabled' in data
|
||||||
|
|
||||||
|
if enabled_changed:
|
||||||
|
if updated_schedule['enabled']:
|
||||||
|
# Re-add to scheduler (replaces existing)
|
||||||
|
current_app.scheduler.add_scheduled_scan(
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
config_file=updated_schedule['config_file'],
|
||||||
|
cron_expression=updated_schedule['cron_expression']
|
||||||
|
)
|
||||||
|
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
|
||||||
|
else:
|
||||||
|
# Remove from scheduler
|
||||||
|
current_app.scheduler.remove_scheduled_scan(schedule_id)
|
||||||
|
logger.info(f"Schedule {schedule_id} disabled and removed from APScheduler")
|
||||||
|
elif (cron_changed or config_changed) and updated_schedule['enabled']:
|
||||||
|
# Reload schedule in APScheduler
|
||||||
|
current_app.scheduler.add_scheduled_scan(
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
config_file=updated_schedule['config_file'],
|
||||||
|
cron_expression=updated_schedule['cron_expression']
|
||||||
|
)
|
||||||
|
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update schedule {schedule_id} in APScheduler: {str(e)}")
|
||||||
|
# Continue anyway - schedule is updated in DB
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Schedule updated successfully',
|
||||||
|
'schedule': updated_schedule
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Schedule not found or validation error
|
||||||
|
if 'not found' in str(e):
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:schedule_id>', methods=['DELETE'])
|
@bp.route('/<int:schedule_id>', methods=['DELETE'])
|
||||||
@@ -107,18 +230,40 @@ def delete_schedule(schedule_id):
|
|||||||
"""
|
"""
|
||||||
Delete a schedule.
|
Delete a schedule.
|
||||||
|
|
||||||
|
Note: Associated scans are NOT deleted (schedule_id becomes null).
|
||||||
|
Active scans will complete normally.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule_id: Schedule ID to delete
|
schedule_id: Schedule ID to delete
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with deletion status
|
JSON response with deletion status
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
return jsonify({
|
# Remove from APScheduler first
|
||||||
'schedule_id': schedule_id,
|
if hasattr(current_app, 'scheduler'):
|
||||||
'status': 'not_implemented',
|
try:
|
||||||
'message': 'Schedule deletion endpoint - to be implemented in Phase 3'
|
current_app.scheduler.remove_scheduled_scan(schedule_id)
|
||||||
}), 501
|
logger.info(f"Schedule {schedule_id} removed from APScheduler")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to remove schedule {schedule_id} from APScheduler: {str(e)}")
|
||||||
|
# Continue anyway
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
|
schedule_service.delete_schedule(schedule_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Schedule deleted successfully',
|
||||||
|
'schedule_id': schedule_id
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Schedule not found
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
|
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
|
||||||
@@ -127,19 +272,47 @@ def trigger_schedule(schedule_id):
|
|||||||
"""
|
"""
|
||||||
Manually trigger a scheduled scan.
|
Manually trigger a scheduled scan.
|
||||||
|
|
||||||
|
Creates a new scan with the schedule's configuration and queues it
|
||||||
|
for immediate execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule_id: Schedule ID to trigger
|
schedule_id: Schedule ID to trigger
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with triggered scan ID
|
JSON response with triggered scan ID
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 3
|
try:
|
||||||
return jsonify({
|
# Get schedule
|
||||||
'schedule_id': schedule_id,
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
'scan_id': None,
|
schedule = schedule_service.get_schedule(schedule_id)
|
||||||
'status': 'not_implemented',
|
|
||||||
'message': 'Manual schedule trigger endpoint - to be implemented in Phase 3'
|
# Trigger scan
|
||||||
}), 501
|
scan_service = ScanService(current_app.db_session)
|
||||||
|
|
||||||
|
# Get scheduler if available
|
||||||
|
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
|
||||||
|
|
||||||
|
scan_id = scan_service.trigger_scan(
|
||||||
|
config_file=schedule['config_file'],
|
||||||
|
triggered_by='manual',
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
scheduler=scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Manual trigger of schedule {schedule_id} created scan {scan_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': 'Scan triggered successfully',
|
||||||
|
'schedule_id': schedule_id,
|
||||||
|
'scan_id': scan_id
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
# Schedule not found
|
||||||
|
return jsonify({'error': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
258
web/api/stats.py
Normal file
258
web/api/stats.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Stats API blueprint.
|
||||||
|
|
||||||
|
Handles endpoints for dashboard statistics, trending data, and analytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
from sqlalchemy import func, Date
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from web.auth.decorators import api_auth_required
|
||||||
|
from web.models import Scan
|
||||||
|
|
||||||
|
bp = Blueprint('stats', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/scan-trend', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def scan_trend():
|
||||||
|
"""
|
||||||
|
Get scan activity trend data for charts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
days: Number of days to include (default: 30, max: 365)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with labels and values arrays for Chart.js
|
||||||
|
{
|
||||||
|
"labels": ["2025-01-01", "2025-01-02", ...],
|
||||||
|
"values": [5, 3, 7, 2, ...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get and validate query parameters
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
|
||||||
|
# Validate days parameter
|
||||||
|
if days < 1:
|
||||||
|
return jsonify({'error': 'days parameter must be at least 1'}), 400
|
||||||
|
if days > 365:
|
||||||
|
return jsonify({'error': 'days parameter cannot exceed 365'}), 400
|
||||||
|
|
||||||
|
# Calculate date range
|
||||||
|
end_date = datetime.utcnow().date()
|
||||||
|
start_date = end_date - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
# Query scan counts per day
|
||||||
|
db_session = current_app.db_session
|
||||||
|
scan_counts = (
|
||||||
|
db_session.query(
|
||||||
|
func.date(Scan.timestamp).label('scan_date'),
|
||||||
|
func.count(Scan.id).label('scan_count')
|
||||||
|
)
|
||||||
|
.filter(func.date(Scan.timestamp) >= start_date)
|
||||||
|
.filter(func.date(Scan.timestamp) <= end_date)
|
||||||
|
.group_by(func.date(Scan.timestamp))
|
||||||
|
.order_by('scan_date')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a dictionary of date -> count
|
||||||
|
scan_dict = {str(row.scan_date): row.scan_count for row in scan_counts}
|
||||||
|
|
||||||
|
# Generate all dates in range (fill missing dates with 0)
|
||||||
|
labels = []
|
||||||
|
values = []
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_str = str(current_date)
|
||||||
|
labels.append(date_str)
|
||||||
|
values.append(scan_dict.get(date_str, 0))
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'labels': labels,
|
||||||
|
'values': values,
|
||||||
|
'start_date': str(start_date),
|
||||||
|
'end_date': str(end_date),
|
||||||
|
'total_scans': sum(values)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in scan_trend: {str(e)}")
|
||||||
|
return jsonify({'error': 'Database error occurred'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scan_trend: {str(e)}")
|
||||||
|
return jsonify({'error': 'An error occurred'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/summary', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def summary():
|
||||||
|
"""
|
||||||
|
Get dashboard summary statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with summary stats
|
||||||
|
{
|
||||||
|
"total_scans": 150,
|
||||||
|
"completed_scans": 140,
|
||||||
|
"failed_scans": 5,
|
||||||
|
"running_scans": 5,
|
||||||
|
"scans_today": 3,
|
||||||
|
"scans_this_week": 15
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_session = current_app.db_session
|
||||||
|
|
||||||
|
# Get total counts by status
|
||||||
|
total_scans = db_session.query(func.count(Scan.id)).scalar() or 0
|
||||||
|
completed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'completed'
|
||||||
|
).scalar() or 0
|
||||||
|
failed_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'failed'
|
||||||
|
).scalar() or 0
|
||||||
|
running_scans = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
Scan.status == 'running'
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Get scans today
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
scans_today = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
func.date(Scan.timestamp) == today
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Get scans this week (last 7 days)
|
||||||
|
week_ago = today - timedelta(days=6)
|
||||||
|
scans_this_week = db_session.query(func.count(Scan.id)).filter(
|
||||||
|
func.date(Scan.timestamp) >= week_ago
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'total_scans': total_scans,
|
||||||
|
'completed_scans': completed_scans,
|
||||||
|
'failed_scans': failed_scans,
|
||||||
|
'running_scans': running_scans,
|
||||||
|
'scans_today': scans_today,
|
||||||
|
'scans_this_week': scans_this_week
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in summary: {str(e)}")
|
||||||
|
return jsonify({'error': 'Database error occurred'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in summary: {str(e)}")
|
||||||
|
return jsonify({'error': 'An error occurred'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/scan-history/<int:scan_id>', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def scan_history(scan_id):
|
||||||
|
"""
|
||||||
|
Get historical trend data for scans with the same config file.
|
||||||
|
|
||||||
|
Returns port counts and other metrics over time for the same
|
||||||
|
configuration/target as the specified scan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id: Reference scan ID
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
limit: Maximum number of historical scans to include (default: 10, max: 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with historical scan data
|
||||||
|
{
|
||||||
|
"scans": [
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"timestamp": "2025-01-01T12:00:00",
|
||||||
|
"title": "Scan title",
|
||||||
|
"port_count": 25,
|
||||||
|
"ip_count": 5
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"labels": ["2025-01-01", ...],
|
||||||
|
"port_counts": [25, 26, 24, ...]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get query parameters
|
||||||
|
limit = request.args.get('limit', 10, type=int)
|
||||||
|
if limit > 50:
|
||||||
|
limit = 50
|
||||||
|
|
||||||
|
db_session = current_app.db_session
|
||||||
|
|
||||||
|
# Get the reference scan to find its config file
|
||||||
|
from web.models import ScanPort
|
||||||
|
reference_scan = db_session.query(Scan).filter(Scan.id == scan_id).first()
|
||||||
|
|
||||||
|
if not reference_scan:
|
||||||
|
return jsonify({'error': 'Scan not found'}), 404
|
||||||
|
|
||||||
|
config_file = reference_scan.config_file
|
||||||
|
|
||||||
|
# Query historical scans with the same config file
|
||||||
|
historical_scans = (
|
||||||
|
db_session.query(Scan)
|
||||||
|
.filter(Scan.config_file == config_file)
|
||||||
|
.filter(Scan.status == 'completed')
|
||||||
|
.order_by(Scan.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build result data
|
||||||
|
scans_data = []
|
||||||
|
labels = []
|
||||||
|
port_counts = []
|
||||||
|
|
||||||
|
for scan in reversed(historical_scans): # Reverse to get chronological order
|
||||||
|
# Count ports for this scan
|
||||||
|
port_count = (
|
||||||
|
db_session.query(func.count(ScanPort.id))
|
||||||
|
.filter(ScanPort.scan_id == scan.id)
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count unique IPs for this scan
|
||||||
|
from web.models import ScanIP
|
||||||
|
ip_count = (
|
||||||
|
db_session.query(func.count(ScanIP.id))
|
||||||
|
.filter(ScanIP.scan_id == scan.id)
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
scans_data.append({
|
||||||
|
'id': scan.id,
|
||||||
|
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||||
|
'title': scan.title,
|
||||||
|
'port_count': port_count,
|
||||||
|
'ip_count': ip_count
|
||||||
|
})
|
||||||
|
|
||||||
|
# For chart data
|
||||||
|
labels.append(scan.timestamp.strftime('%Y-%m-%d %H:%M') if scan.timestamp else '')
|
||||||
|
port_counts.append(port_count)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'scans': scans_data,
|
||||||
|
'labels': labels,
|
||||||
|
'port_counts': port_counts,
|
||||||
|
'config_file': config_file
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Database error in scan_history: {str(e)}")
|
||||||
|
return jsonify({'error': 'Database error occurred'}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scan_history: {str(e)}")
|
||||||
|
return jsonify({'error': 'An error occurred'}), 500
|
||||||
14
web/app.py
14
web/app.py
@@ -294,11 +294,23 @@ def init_scheduler(app: Flask) -> None:
|
|||||||
app: Flask application instance
|
app: Flask application instance
|
||||||
"""
|
"""
|
||||||
from web.services.scheduler_service import SchedulerService
|
from web.services.scheduler_service import SchedulerService
|
||||||
|
from web.services.scan_service import ScanService
|
||||||
|
|
||||||
# Create and initialize scheduler
|
# Create and initialize scheduler
|
||||||
scheduler = SchedulerService()
|
scheduler = SchedulerService()
|
||||||
scheduler.init_scheduler(app)
|
scheduler.init_scheduler(app)
|
||||||
|
|
||||||
|
# Perform startup tasks with app context for database access
|
||||||
|
with app.app_context():
|
||||||
|
# Clean up any orphaned scans from previous crashes/restarts
|
||||||
|
scan_service = ScanService(app.db_session)
|
||||||
|
orphaned_count = scan_service.cleanup_orphaned_scans()
|
||||||
|
if orphaned_count > 0:
|
||||||
|
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
|
||||||
|
|
||||||
|
# Load all enabled schedules from database
|
||||||
|
scheduler.load_schedules_on_startup()
|
||||||
|
|
||||||
# Store in app context for access from routes
|
# Store in app context for access from routes
|
||||||
app.scheduler = scheduler
|
app.scheduler = scheduler
|
||||||
|
|
||||||
@@ -317,6 +329,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
from web.api.schedules import bp as schedules_bp
|
from web.api.schedules import bp as schedules_bp
|
||||||
from web.api.alerts import bp as alerts_bp
|
from web.api.alerts import bp as alerts_bp
|
||||||
from web.api.settings import bp as settings_bp
|
from web.api.settings import bp as settings_bp
|
||||||
|
from web.api.stats import bp as stats_bp
|
||||||
from web.auth.routes import bp as auth_bp
|
from web.auth.routes import bp as auth_bp
|
||||||
from web.routes.main import bp as main_bp
|
from web.routes.main import bp as main_bp
|
||||||
|
|
||||||
@@ -331,6 +344,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
||||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
||||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||||
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||||
|
|
||||||
app.logger.info("Blueprints registered")
|
app.logger.info("Blueprints registered")
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,14 @@ def execute_scan(scan_id: int, config_file: str, db_url: str):
|
|||||||
|
|
||||||
logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}")
|
logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}")
|
||||||
|
|
||||||
|
# Convert config_file to full path if it's just a filename
|
||||||
|
if not config_file.startswith('/'):
|
||||||
|
config_path = f'/app/configs/{config_file}'
|
||||||
|
else:
|
||||||
|
config_path = config_file
|
||||||
|
|
||||||
# Initialize scanner
|
# Initialize scanner
|
||||||
scanner = SneakyScanner(config_file)
|
scanner = SneakyScanner(config_path)
|
||||||
|
|
||||||
# Execute scan
|
# Execute scan
|
||||||
logger.info(f"Scan {scan_id}: Running scanner...")
|
logger.info(f"Scan {scan_id}: Running scanner...")
|
||||||
|
|||||||
@@ -35,8 +35,20 @@ def dashboard():
|
|||||||
Returns:
|
Returns:
|
||||||
Rendered dashboard template
|
Rendered dashboard template
|
||||||
"""
|
"""
|
||||||
# TODO: Phase 5 - Add dashboard stats and recent scans
|
import os
|
||||||
return render_template('dashboard.html')
|
|
||||||
|
# Get list of available config files
|
||||||
|
configs_dir = '/app/configs'
|
||||||
|
config_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(configs_dir):
|
||||||
|
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||||
|
config_files.sort()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing config files: {e}")
|
||||||
|
|
||||||
|
return render_template('dashboard.html', config_files=config_files)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/scans')
|
@bp.route('/scans')
|
||||||
@@ -48,8 +60,20 @@ def scans():
|
|||||||
Returns:
|
Returns:
|
||||||
Rendered scans list template
|
Rendered scans list template
|
||||||
"""
|
"""
|
||||||
# TODO: Phase 5 - Implement scans list page
|
import os
|
||||||
return render_template('scans.html')
|
|
||||||
|
# Get list of available config files
|
||||||
|
configs_dir = '/app/configs'
|
||||||
|
config_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(configs_dir):
|
||||||
|
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||||
|
config_files.sort()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing config files: {e}")
|
||||||
|
|
||||||
|
return render_template('scans.html', config_files=config_files)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/scans/<int:scan_id>')
|
@bp.route('/scans/<int:scan_id>')
|
||||||
@@ -66,3 +90,75 @@ def scan_detail(scan_id):
|
|||||||
"""
|
"""
|
||||||
# TODO: Phase 5 - Implement scan detail page
|
# TODO: Phase 5 - Implement scan detail page
|
||||||
return render_template('scan_detail.html', scan_id=scan_id)
|
return render_template('scan_detail.html', scan_id=scan_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
|
||||||
|
@login_required
|
||||||
|
def compare_scans(scan_id1, scan_id2):
|
||||||
|
"""
|
||||||
|
Scan comparison page - shows differences between two scans.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_id1: First (older) scan ID
|
||||||
|
scan_id2: Second (newer) scan ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered comparison template
|
||||||
|
"""
|
||||||
|
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/schedules')
|
||||||
|
@login_required
|
||||||
|
def schedules():
|
||||||
|
"""
|
||||||
|
Schedules list page - shows all scheduled scans.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered schedules list template
|
||||||
|
"""
|
||||||
|
return render_template('schedules.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/schedules/create')
|
||||||
|
@login_required
|
||||||
|
def create_schedule():
|
||||||
|
"""
|
||||||
|
Create new schedule form page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered schedule create template with available config files
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Get list of available config files
|
||||||
|
configs_dir = '/app/configs'
|
||||||
|
config_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(configs_dir):
|
||||||
|
config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')]
|
||||||
|
config_files.sort()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing config files: {e}")
|
||||||
|
|
||||||
|
return render_template('schedule_create.html', config_files=config_files)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/schedules/<int:schedule_id>/edit')
|
||||||
|
@login_required
|
||||||
|
def edit_schedule(schedule_id):
|
||||||
|
"""
|
||||||
|
Edit existing schedule form page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID to edit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered schedule edit template
|
||||||
|
"""
|
||||||
|
from flask import flash
|
||||||
|
|
||||||
|
# Note: Schedule data is loaded via AJAX in the template
|
||||||
|
# This just renders the page with the schedule_id in the URL
|
||||||
|
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
||||||
|
|||||||
@@ -66,9 +66,15 @@ class ScanService:
|
|||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise ValueError(f"Invalid config file: {error_msg}")
|
raise ValueError(f"Invalid config file: {error_msg}")
|
||||||
|
|
||||||
|
# Convert config_file to full path if it's just a filename
|
||||||
|
if not config_file.startswith('/'):
|
||||||
|
config_path = f'/app/configs/{config_file}'
|
||||||
|
else:
|
||||||
|
config_path = config_file
|
||||||
|
|
||||||
# Load config to get title
|
# Load config to get title
|
||||||
import yaml
|
import yaml
|
||||||
with open(config_file, 'r') as f:
|
with open(config_path, 'r') as f:
|
||||||
config = yaml.safe_load(f)
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
# Create scan record
|
# Create scan record
|
||||||
@@ -262,6 +268,53 @@ class ScanService:
|
|||||||
|
|
||||||
return status_info
|
return status_info
|
||||||
|
|
||||||
|
def cleanup_orphaned_scans(self) -> int:
|
||||||
|
"""
|
||||||
|
Clean up orphaned scans that are stuck in 'running' status.
|
||||||
|
|
||||||
|
This should be called on application startup to handle scans that
|
||||||
|
were running when the system crashed or was restarted.
|
||||||
|
|
||||||
|
Scans in 'running' status are marked as 'failed' with an appropriate
|
||||||
|
error message indicating they were orphaned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of orphaned scans cleaned up
|
||||||
|
"""
|
||||||
|
# Find all scans with status='running'
|
||||||
|
orphaned_scans = self.db.query(Scan).filter(Scan.status == 'running').all()
|
||||||
|
|
||||||
|
if not orphaned_scans:
|
||||||
|
logger.info("No orphaned scans found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = len(orphaned_scans)
|
||||||
|
logger.warning(f"Found {count} orphaned scan(s) in 'running' status, marking as failed")
|
||||||
|
|
||||||
|
# Mark each orphaned scan as failed
|
||||||
|
for scan in orphaned_scans:
|
||||||
|
scan.status = 'failed'
|
||||||
|
scan.completed_at = datetime.utcnow()
|
||||||
|
scan.error_message = (
|
||||||
|
"Scan was interrupted by system shutdown or crash. "
|
||||||
|
"The scan was running but did not complete normally."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate duration if we have a started_at time
|
||||||
|
if scan.started_at:
|
||||||
|
duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||||
|
scan.duration = duration
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Marked orphaned scan {scan.id} as failed "
|
||||||
|
f"(started: {scan.started_at.isoformat() if scan.started_at else 'unknown'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Cleaned up {count} orphaned scan(s)")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
def _save_scan_to_db(self, report: Dict[str, Any], scan_id: int,
|
||||||
status: str = 'completed') -> None:
|
status: str = 'completed') -> None:
|
||||||
"""
|
"""
|
||||||
@@ -605,3 +658,333 @@ class ScanService:
|
|||||||
result['cipher_suites'] = []
|
result['cipher_suites'] = []
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def compare_scans(self, scan1_id: int, scan2_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Compare two scans and return the differences.
|
||||||
|
|
||||||
|
Compares ports, services, and certificates between two scans,
|
||||||
|
highlighting added, removed, and changed items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan1_id: ID of the first (older) scan
|
||||||
|
scan2_id: ID of the second (newer) scan
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with comparison results, or None if either scan not found
|
||||||
|
{
|
||||||
|
'scan1': {...}, # Scan 1 summary
|
||||||
|
'scan2': {...}, # Scan 2 summary
|
||||||
|
'ports': {
|
||||||
|
'added': [...],
|
||||||
|
'removed': [...],
|
||||||
|
'unchanged': [...]
|
||||||
|
},
|
||||||
|
'services': {
|
||||||
|
'added': [...],
|
||||||
|
'removed': [...],
|
||||||
|
'changed': [...]
|
||||||
|
},
|
||||||
|
'certificates': {
|
||||||
|
'added': [...],
|
||||||
|
'removed': [...],
|
||||||
|
'changed': [...]
|
||||||
|
},
|
||||||
|
'drift_score': 0.0-1.0
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Get both scans
|
||||||
|
scan1 = self.get_scan(scan1_id)
|
||||||
|
scan2 = self.get_scan(scan2_id)
|
||||||
|
|
||||||
|
if not scan1 or not scan2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract port data
|
||||||
|
ports1 = self._extract_ports_from_scan(scan1)
|
||||||
|
ports2 = self._extract_ports_from_scan(scan2)
|
||||||
|
|
||||||
|
# Compare ports
|
||||||
|
ports_comparison = self._compare_ports(ports1, ports2)
|
||||||
|
|
||||||
|
# Extract service data
|
||||||
|
services1 = self._extract_services_from_scan(scan1)
|
||||||
|
services2 = self._extract_services_from_scan(scan2)
|
||||||
|
|
||||||
|
# Compare services
|
||||||
|
services_comparison = self._compare_services(services1, services2)
|
||||||
|
|
||||||
|
# Extract certificate data
|
||||||
|
certs1 = self._extract_certificates_from_scan(scan1)
|
||||||
|
certs2 = self._extract_certificates_from_scan(scan2)
|
||||||
|
|
||||||
|
# Compare certificates
|
||||||
|
certificates_comparison = self._compare_certificates(certs1, certs2)
|
||||||
|
|
||||||
|
# Calculate drift score (0.0 = identical, 1.0 = completely different)
|
||||||
|
drift_score = self._calculate_drift_score(
|
||||||
|
ports_comparison,
|
||||||
|
services_comparison,
|
||||||
|
certificates_comparison
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scan1': {
|
||||||
|
'id': scan1['id'],
|
||||||
|
'timestamp': scan1['timestamp'],
|
||||||
|
'title': scan1['title'],
|
||||||
|
'status': scan1['status']
|
||||||
|
},
|
||||||
|
'scan2': {
|
||||||
|
'id': scan2['id'],
|
||||||
|
'timestamp': scan2['timestamp'],
|
||||||
|
'title': scan2['title'],
|
||||||
|
'status': scan2['status']
|
||||||
|
},
|
||||||
|
'ports': ports_comparison,
|
||||||
|
'services': services_comparison,
|
||||||
|
'certificates': certificates_comparison,
|
||||||
|
'drift_score': drift_score
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_ports_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract port information from a scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping "ip:port:protocol" to port details
|
||||||
|
"""
|
||||||
|
ports = {}
|
||||||
|
for site in scan.get('sites', []):
|
||||||
|
for ip_data in site.get('ips', []):
|
||||||
|
ip_addr = ip_data['address']
|
||||||
|
for port_data in ip_data.get('ports', []):
|
||||||
|
key = f"{ip_addr}:{port_data['port']}:{port_data['protocol']}"
|
||||||
|
ports[key] = {
|
||||||
|
'ip': ip_addr,
|
||||||
|
'port': port_data['port'],
|
||||||
|
'protocol': port_data['protocol'],
|
||||||
|
'state': port_data.get('state', 'unknown'),
|
||||||
|
'expected': port_data.get('expected')
|
||||||
|
}
|
||||||
|
return ports
|
||||||
|
|
||||||
|
def _extract_services_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract service information from a scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping "ip:port:protocol" to service details
|
||||||
|
"""
|
||||||
|
services = {}
|
||||||
|
for site in scan.get('sites', []):
|
||||||
|
for ip_data in site.get('ips', []):
|
||||||
|
ip_addr = ip_data['address']
|
||||||
|
for port_data in ip_data.get('ports', []):
|
||||||
|
port_num = port_data['port']
|
||||||
|
protocol = port_data['protocol']
|
||||||
|
key = f"{ip_addr}:{port_num}:{protocol}"
|
||||||
|
|
||||||
|
# Get first service (usually only one per port)
|
||||||
|
port_services = port_data.get('services', [])
|
||||||
|
if port_services:
|
||||||
|
svc = port_services[0]
|
||||||
|
services[key] = {
|
||||||
|
'ip': ip_addr,
|
||||||
|
'port': port_num,
|
||||||
|
'protocol': protocol,
|
||||||
|
'service_name': svc.get('service_name'),
|
||||||
|
'product': svc.get('product'),
|
||||||
|
'version': svc.get('version'),
|
||||||
|
'extrainfo': svc.get('extrainfo')
|
||||||
|
}
|
||||||
|
return services
|
||||||
|
|
||||||
|
def _extract_certificates_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract certificate information from a scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping "ip:port" to certificate details
|
||||||
|
"""
|
||||||
|
certificates = {}
|
||||||
|
for site in scan.get('sites', []):
|
||||||
|
for ip_data in site.get('ips', []):
|
||||||
|
ip_addr = ip_data['address']
|
||||||
|
for port_data in ip_data.get('ports', []):
|
||||||
|
port_num = port_data['port']
|
||||||
|
protocol = port_data['protocol']
|
||||||
|
|
||||||
|
# Get certificates from services
|
||||||
|
for svc in port_data.get('services', []):
|
||||||
|
if svc.get('certificates'):
|
||||||
|
for cert in svc['certificates']:
|
||||||
|
key = f"{ip_addr}:{port_num}"
|
||||||
|
certificates[key] = {
|
||||||
|
'ip': ip_addr,
|
||||||
|
'port': port_num,
|
||||||
|
'subject': cert.get('subject'),
|
||||||
|
'issuer': cert.get('issuer'),
|
||||||
|
'not_valid_after': cert.get('not_valid_after'),
|
||||||
|
'days_until_expiry': cert.get('days_until_expiry'),
|
||||||
|
'is_self_signed': cert.get('is_self_signed')
|
||||||
|
}
|
||||||
|
return certificates
|
||||||
|
|
||||||
|
def _compare_ports(self, ports1: Dict, ports2: Dict) -> Dict[str, List]:
|
||||||
|
"""
|
||||||
|
Compare port sets between two scans.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with added, removed, and unchanged ports
|
||||||
|
"""
|
||||||
|
keys1 = set(ports1.keys())
|
||||||
|
keys2 = set(ports2.keys())
|
||||||
|
|
||||||
|
added_keys = keys2 - keys1
|
||||||
|
removed_keys = keys1 - keys2
|
||||||
|
unchanged_keys = keys1 & keys2
|
||||||
|
|
||||||
|
return {
|
||||||
|
'added': [ports2[k] for k in sorted(added_keys)],
|
||||||
|
'removed': [ports1[k] for k in sorted(removed_keys)],
|
||||||
|
'unchanged': [ports2[k] for k in sorted(unchanged_keys)]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compare_services(self, services1: Dict, services2: Dict) -> Dict[str, List]:
|
||||||
|
"""
|
||||||
|
Compare services between two scans.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with added, removed, and changed services
|
||||||
|
"""
|
||||||
|
keys1 = set(services1.keys())
|
||||||
|
keys2 = set(services2.keys())
|
||||||
|
|
||||||
|
added_keys = keys2 - keys1
|
||||||
|
removed_keys = keys1 - keys2
|
||||||
|
common_keys = keys1 & keys2
|
||||||
|
|
||||||
|
# Find changed services (same port, different version/product)
|
||||||
|
changed = []
|
||||||
|
for key in sorted(common_keys):
|
||||||
|
svc1 = services1[key]
|
||||||
|
svc2 = services2[key]
|
||||||
|
|
||||||
|
# Check if service details changed
|
||||||
|
if (svc1.get('product') != svc2.get('product') or
|
||||||
|
svc1.get('version') != svc2.get('version') or
|
||||||
|
svc1.get('service_name') != svc2.get('service_name')):
|
||||||
|
changed.append({
|
||||||
|
'ip': svc2['ip'],
|
||||||
|
'port': svc2['port'],
|
||||||
|
'protocol': svc2['protocol'],
|
||||||
|
'old': {
|
||||||
|
'service_name': svc1.get('service_name'),
|
||||||
|
'product': svc1.get('product'),
|
||||||
|
'version': svc1.get('version')
|
||||||
|
},
|
||||||
|
'new': {
|
||||||
|
'service_name': svc2.get('service_name'),
|
||||||
|
'product': svc2.get('product'),
|
||||||
|
'version': svc2.get('version')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'added': [services2[k] for k in sorted(added_keys)],
|
||||||
|
'removed': [services1[k] for k in sorted(removed_keys)],
|
||||||
|
'changed': changed
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compare_certificates(self, certs1: Dict, certs2: Dict) -> Dict[str, List]:
|
||||||
|
"""
|
||||||
|
Compare certificates between two scans.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with added, removed, and changed certificates
|
||||||
|
"""
|
||||||
|
keys1 = set(certs1.keys())
|
||||||
|
keys2 = set(certs2.keys())
|
||||||
|
|
||||||
|
added_keys = keys2 - keys1
|
||||||
|
removed_keys = keys1 - keys2
|
||||||
|
common_keys = keys1 & keys2
|
||||||
|
|
||||||
|
# Find changed certificates (same IP:port, different cert details)
|
||||||
|
changed = []
|
||||||
|
for key in sorted(common_keys):
|
||||||
|
cert1 = certs1[key]
|
||||||
|
cert2 = certs2[key]
|
||||||
|
|
||||||
|
# Check if certificate changed
|
||||||
|
if (cert1.get('subject') != cert2.get('subject') or
|
||||||
|
cert1.get('issuer') != cert2.get('issuer') or
|
||||||
|
cert1.get('not_valid_after') != cert2.get('not_valid_after')):
|
||||||
|
changed.append({
|
||||||
|
'ip': cert2['ip'],
|
||||||
|
'port': cert2['port'],
|
||||||
|
'old': {
|
||||||
|
'subject': cert1.get('subject'),
|
||||||
|
'issuer': cert1.get('issuer'),
|
||||||
|
'not_valid_after': cert1.get('not_valid_after'),
|
||||||
|
'days_until_expiry': cert1.get('days_until_expiry')
|
||||||
|
},
|
||||||
|
'new': {
|
||||||
|
'subject': cert2.get('subject'),
|
||||||
|
'issuer': cert2.get('issuer'),
|
||||||
|
'not_valid_after': cert2.get('not_valid_after'),
|
||||||
|
'days_until_expiry': cert2.get('days_until_expiry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'added': [certs2[k] for k in sorted(added_keys)],
|
||||||
|
'removed': [certs1[k] for k in sorted(removed_keys)],
|
||||||
|
'changed': changed
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_drift_score(self, ports_comp: Dict, services_comp: Dict,
|
||||||
|
certs_comp: Dict) -> float:
|
||||||
|
"""
|
||||||
|
Calculate drift score based on comparison results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float between 0.0 (identical) and 1.0 (completely different)
|
||||||
|
"""
|
||||||
|
# Count total items in both scans
|
||||||
|
total_ports = (
|
||||||
|
len(ports_comp['added']) +
|
||||||
|
len(ports_comp['removed']) +
|
||||||
|
len(ports_comp['unchanged'])
|
||||||
|
)
|
||||||
|
|
||||||
|
total_services = (
|
||||||
|
len(services_comp['added']) +
|
||||||
|
len(services_comp['removed']) +
|
||||||
|
len(services_comp['changed']) +
|
||||||
|
max(0, len(ports_comp['unchanged']) - len(services_comp['changed']))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count changed items
|
||||||
|
changed_ports = len(ports_comp['added']) + len(ports_comp['removed'])
|
||||||
|
changed_services = (
|
||||||
|
len(services_comp['added']) +
|
||||||
|
len(services_comp['removed']) +
|
||||||
|
len(services_comp['changed'])
|
||||||
|
)
|
||||||
|
changed_certs = (
|
||||||
|
len(certs_comp['added']) +
|
||||||
|
len(certs_comp['removed']) +
|
||||||
|
len(certs_comp['changed'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate weighted drift score
|
||||||
|
# Ports have 50% weight, services 30%, certificates 20%
|
||||||
|
port_drift = changed_ports / max(total_ports, 1)
|
||||||
|
service_drift = changed_services / max(total_services, 1)
|
||||||
|
cert_drift = changed_certs / max(len(certs_comp['added']) + len(certs_comp['removed']) + len(certs_comp['changed']), 1)
|
||||||
|
|
||||||
|
drift_score = (port_drift * 0.5) + (service_drift * 0.3) + (cert_drift * 0.2)
|
||||||
|
|
||||||
|
return round(min(drift_score, 1.0), 3) # Cap at 1.0 and round to 3 decimals
|
||||||
|
|||||||
483
web/services/schedule_service.py
Normal file
483
web/services/schedule_service.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""
|
||||||
|
Schedule service for managing scheduled scan operations.
|
||||||
|
|
||||||
|
This service handles the business logic for creating, updating, and managing
|
||||||
|
scheduled scans with cron expressions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from croniter import croniter
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.models import Schedule, Scan
|
||||||
|
from web.utils.pagination import paginate, PaginatedResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleService:
|
||||||
|
"""
|
||||||
|
Service for managing scheduled scans.
|
||||||
|
|
||||||
|
Handles schedule lifecycle: creation, validation, updating,
|
||||||
|
and cron expression processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_session: Session):
|
||||||
|
"""
|
||||||
|
Initialize schedule service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: SQLAlchemy database session
|
||||||
|
"""
|
||||||
|
self.db = db_session
|
||||||
|
|
||||||
|
def create_schedule(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
config_file: str,
|
||||||
|
cron_expression: str,
|
||||||
|
enabled: bool = True
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Create a new schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Human-readable schedule name
|
||||||
|
config_file: Path to YAML configuration file
|
||||||
|
cron_expression: Cron expression (e.g., '0 2 * * *')
|
||||||
|
enabled: Whether schedule is active
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schedule ID of the created schedule
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If cron expression is invalid or config file doesn't exist
|
||||||
|
"""
|
||||||
|
# Validate cron expression
|
||||||
|
is_valid, error_msg = self.validate_cron_expression(cron_expression)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||||
|
|
||||||
|
# Validate config file exists
|
||||||
|
# If config_file is just a filename, prepend the configs directory
|
||||||
|
if not config_file.startswith('/'):
|
||||||
|
config_file_path = os.path.join('/app/configs', config_file)
|
||||||
|
else:
|
||||||
|
config_file_path = config_file
|
||||||
|
|
||||||
|
if not os.path.isfile(config_file_path):
|
||||||
|
raise ValueError(f"Config file not found: {config_file}")
|
||||||
|
|
||||||
|
# Calculate next run time
|
||||||
|
next_run = self.calculate_next_run(cron_expression) if enabled else None
|
||||||
|
|
||||||
|
# Create schedule record
|
||||||
|
schedule = Schedule(
|
||||||
|
name=name,
|
||||||
|
config_file=config_file,
|
||||||
|
cron_expression=cron_expression,
|
||||||
|
enabled=enabled,
|
||||||
|
last_run=None,
|
||||||
|
next_run=next_run,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(schedule)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(schedule)
|
||||||
|
|
||||||
|
logger.info(f"Schedule {schedule.id} created: '{name}' with cron '{cron_expression}'")
|
||||||
|
|
||||||
|
return schedule.id
|
||||||
|
|
||||||
|
def get_schedule(self, schedule_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get schedule details by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schedule dictionary with details and execution history
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schedule not found
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise ValueError(f"Schedule {schedule_id} not found")
|
||||||
|
|
||||||
|
# Convert to dict and include history
|
||||||
|
schedule_dict = self._schedule_to_dict(schedule)
|
||||||
|
schedule_dict['history'] = self.get_schedule_history(schedule_id, limit=10)
|
||||||
|
|
||||||
|
return schedule_dict
|
||||||
|
|
||||||
|
def list_schedules(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 20,
|
||||||
|
enabled_filter: Optional[bool] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List all schedules with pagination and filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Items per page
|
||||||
|
enabled_filter: Filter by enabled status (None = all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with paginated schedules:
|
||||||
|
{
|
||||||
|
'schedules': [...],
|
||||||
|
'total': int,
|
||||||
|
'page': int,
|
||||||
|
'per_page': int,
|
||||||
|
'pages': int
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Build query
|
||||||
|
query = self.db.query(Schedule)
|
||||||
|
|
||||||
|
# Apply filter
|
||||||
|
if enabled_filter is not None:
|
||||||
|
query = query.filter(Schedule.enabled == enabled_filter)
|
||||||
|
|
||||||
|
# Order by next_run (nulls last), then by name
|
||||||
|
query = query.order_by(Schedule.next_run.is_(None), Schedule.next_run, Schedule.name)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
result = paginate(query, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
# Convert schedules to dicts
|
||||||
|
schedules = [self._schedule_to_dict(s) for s in result.items]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'schedules': schedules,
|
||||||
|
'total': result.total,
|
||||||
|
'page': result.page,
|
||||||
|
'per_page': result.per_page,
|
||||||
|
'pages': result.pages
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_schedule(
|
||||||
|
self,
|
||||||
|
schedule_id: int,
|
||||||
|
**updates: Any
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Update schedule fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
**updates: Fields to update (name, config_file, cron_expression, enabled)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated schedule dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schedule not found or invalid updates
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise ValueError(f"Schedule {schedule_id} not found")
|
||||||
|
|
||||||
|
# Validate cron expression if being updated
|
||||||
|
if 'cron_expression' in updates:
|
||||||
|
is_valid, error_msg = self.validate_cron_expression(updates['cron_expression'])
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||||
|
# Recalculate next_run
|
||||||
|
if schedule.enabled or updates.get('enabled', False):
|
||||||
|
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
|
||||||
|
|
||||||
|
# Validate config file if being updated
|
||||||
|
if 'config_file' in updates:
|
||||||
|
config_file = updates['config_file']
|
||||||
|
# If config_file is just a filename, prepend the configs directory
|
||||||
|
if not config_file.startswith('/'):
|
||||||
|
config_file_path = os.path.join('/app/configs', config_file)
|
||||||
|
else:
|
||||||
|
config_file_path = config_file
|
||||||
|
|
||||||
|
if not os.path.isfile(config_file_path):
|
||||||
|
raise ValueError(f"Config file not found: {updates['config_file']}")
|
||||||
|
|
||||||
|
# Handle enabled toggle
|
||||||
|
if 'enabled' in updates:
|
||||||
|
if updates['enabled'] and not schedule.enabled:
|
||||||
|
# Being enabled - calculate next_run
|
||||||
|
cron_expr = updates.get('cron_expression', schedule.cron_expression)
|
||||||
|
updates['next_run'] = self.calculate_next_run(cron_expr)
|
||||||
|
elif not updates['enabled'] and schedule.enabled:
|
||||||
|
# Being disabled - clear next_run
|
||||||
|
updates['next_run'] = None
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(schedule, key):
|
||||||
|
setattr(schedule, key, value)
|
||||||
|
|
||||||
|
schedule.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(schedule)
|
||||||
|
|
||||||
|
logger.info(f"Schedule {schedule_id} updated: {list(updates.keys())}")
|
||||||
|
|
||||||
|
return self._schedule_to_dict(schedule)
|
||||||
|
|
||||||
|
def delete_schedule(self, schedule_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a schedule.
|
||||||
|
|
||||||
|
Note: Associated scans are NOT deleted (schedule_id becomes null).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schedule not found
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise ValueError(f"Schedule {schedule_id} not found")
|
||||||
|
|
||||||
|
schedule_name = schedule.name
|
||||||
|
|
||||||
|
self.db.delete(schedule)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Schedule {schedule_id} ('{schedule_name}') deleted")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_enabled(self, schedule_id: int, enabled: bool) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Enable or disable a schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
enabled: New enabled status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated schedule dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schedule not found
|
||||||
|
"""
|
||||||
|
return self.update_schedule(schedule_id, enabled=enabled)
|
||||||
|
|
||||||
|
def update_run_times(
|
||||||
|
self,
|
||||||
|
schedule_id: int,
|
||||||
|
last_run: datetime,
|
||||||
|
next_run: datetime
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update last_run and next_run timestamps.
|
||||||
|
|
||||||
|
Called after each execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
last_run: Last execution time
|
||||||
|
next_run: Next scheduled execution time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated successfully
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If schedule not found
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise ValueError(f"Schedule {schedule_id} not found")
|
||||||
|
|
||||||
|
schedule.last_run = last_run
|
||||||
|
schedule.next_run = next_run
|
||||||
|
schedule.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.debug(f"Schedule {schedule_id} run times updated: last={last_run}, next={next_run}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_cron_expression(self, cron_expr: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate a cron expression.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cron_expr: Cron expression to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
- (True, None) if valid
|
||||||
|
- (False, error_message) if invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Try to create a croniter instance
|
||||||
|
base_time = datetime.utcnow()
|
||||||
|
cron = croniter(cron_expr, base_time)
|
||||||
|
|
||||||
|
# Try to get the next run time (validates the expression)
|
||||||
|
cron.get_next(datetime)
|
||||||
|
|
||||||
|
return (True, None)
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
return (False, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return (False, f"Unexpected error: {str(e)}")
|
||||||
|
|
||||||
|
def calculate_next_run(
|
||||||
|
self,
|
||||||
|
cron_expr: str,
|
||||||
|
from_time: Optional[datetime] = None
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next run time from cron expression.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cron_expr: Cron expression
|
||||||
|
from_time: Base time (defaults to now UTC)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next run datetime (UTC)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If cron expression is invalid
|
||||||
|
"""
|
||||||
|
if from_time is None:
|
||||||
|
from_time = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cron = croniter(cron_expr, from_time)
|
||||||
|
return cron.get_next(datetime)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}")
|
||||||
|
|
||||||
|
def get_schedule_history(
|
||||||
|
self,
|
||||||
|
schedule_id: int,
|
||||||
|
limit: int = 10
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get recent scans triggered by this schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
limit: Maximum number of scans to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scan dictionaries (recent first)
|
||||||
|
"""
|
||||||
|
scans = (
|
||||||
|
self.db.query(Scan)
|
||||||
|
.filter(Scan.schedule_id == schedule_id)
|
||||||
|
.order_by(Scan.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': scan.id,
|
||||||
|
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
|
||||||
|
'status': scan.status,
|
||||||
|
'title': scan.title,
|
||||||
|
'config_file': scan.config_file
|
||||||
|
}
|
||||||
|
for scan in scans
|
||||||
|
]
|
||||||
|
|
||||||
|
def _schedule_to_dict(self, schedule: Schedule) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert Schedule model to dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: Schedule model instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': schedule.id,
|
||||||
|
'name': schedule.name,
|
||||||
|
'config_file': schedule.config_file,
|
||||||
|
'cron_expression': schedule.cron_expression,
|
||||||
|
'enabled': schedule.enabled,
|
||||||
|
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,
|
||||||
|
'next_run': schedule.next_run.isoformat() if schedule.next_run else None,
|
||||||
|
'next_run_relative': self._get_relative_time(schedule.next_run) if schedule.next_run else None,
|
||||||
|
'created_at': schedule.created_at.isoformat() if schedule.created_at else None,
|
||||||
|
'updated_at': schedule.updated_at.isoformat() if schedule.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_relative_time(self, dt: Optional[datetime]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Format datetime as relative time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime to format (UTC)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable relative time (e.g., "in 2 hours", "yesterday")
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
diff = dt - now
|
||||||
|
|
||||||
|
# Future times
|
||||||
|
if diff.total_seconds() > 0:
|
||||||
|
seconds = int(diff.total_seconds())
|
||||||
|
|
||||||
|
if seconds < 60:
|
||||||
|
return "in less than a minute"
|
||||||
|
elif seconds < 3600:
|
||||||
|
minutes = seconds // 60
|
||||||
|
return f"in {minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
|
elif seconds < 86400:
|
||||||
|
hours = seconds // 3600
|
||||||
|
return f"in {hours} hour{'s' if hours != 1 else ''}"
|
||||||
|
elif seconds < 604800:
|
||||||
|
days = seconds // 86400
|
||||||
|
return f"in {days} day{'s' if days != 1 else ''}"
|
||||||
|
else:
|
||||||
|
weeks = seconds // 604800
|
||||||
|
return f"in {weeks} week{'s' if weeks != 1 else ''}"
|
||||||
|
|
||||||
|
# Past times
|
||||||
|
else:
|
||||||
|
seconds = int(-diff.total_seconds())
|
||||||
|
|
||||||
|
if seconds < 60:
|
||||||
|
return "less than a minute ago"
|
||||||
|
elif seconds < 3600:
|
||||||
|
minutes = seconds // 60
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
||||||
|
elif seconds < 86400:
|
||||||
|
hours = seconds // 3600
|
||||||
|
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
||||||
|
elif seconds < 604800:
|
||||||
|
days = seconds // 86400
|
||||||
|
return f"{days} day{'s' if days != 1 else ''} ago"
|
||||||
|
else:
|
||||||
|
weeks = seconds // 604800
|
||||||
|
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
|
||||||
@@ -6,7 +6,7 @@ scan execution and future scheduled scanning capabilities.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -63,11 +63,13 @@ class SchedulerService:
|
|||||||
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
|
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create scheduler
|
# Create scheduler with local system timezone
|
||||||
|
# This allows users to schedule jobs using their local time
|
||||||
|
# APScheduler will automatically use the system's local timezone
|
||||||
self.scheduler = BackgroundScheduler(
|
self.scheduler = BackgroundScheduler(
|
||||||
executors=executors,
|
executors=executors,
|
||||||
job_defaults=job_defaults,
|
job_defaults=job_defaults
|
||||||
timezone='UTC'
|
# timezone defaults to local system timezone
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start scheduler
|
# Start scheduler
|
||||||
@@ -90,6 +92,63 @@ class SchedulerService:
|
|||||||
logger.info("APScheduler shutdown complete")
|
logger.info("APScheduler shutdown complete")
|
||||||
self.scheduler = None
|
self.scheduler = None
|
||||||
|
|
||||||
|
def load_schedules_on_startup(self):
|
||||||
|
"""
|
||||||
|
Load all enabled schedules from database and register with APScheduler.
|
||||||
|
|
||||||
|
Should be called after init_scheduler() to restore scheduled jobs
|
||||||
|
that were active when the application last shutdown.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If scheduler not initialized
|
||||||
|
"""
|
||||||
|
if not self.scheduler:
|
||||||
|
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from web.models import Schedule
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create database session
|
||||||
|
engine = create_engine(self.db_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query all enabled schedules
|
||||||
|
enabled_schedules = (
|
||||||
|
session.query(Schedule)
|
||||||
|
.filter(Schedule.enabled == True)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup")
|
||||||
|
|
||||||
|
# Register each schedule with APScheduler
|
||||||
|
for schedule in enabled_schedules:
|
||||||
|
try:
|
||||||
|
self.add_scheduled_scan(
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
config_file=schedule.config_file,
|
||||||
|
cron_expression=schedule.cron_expression
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Schedule loading complete")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
||||||
"""
|
"""
|
||||||
Queue a scan for immediate background execution.
|
Queue a scan for immediate background execution.
|
||||||
@@ -136,35 +195,29 @@ class SchedulerService:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If scheduler not initialized
|
RuntimeError: If scheduler not initialized
|
||||||
ValueError: If cron expression is invalid
|
ValueError: If cron expression is invalid
|
||||||
|
|
||||||
Note:
|
|
||||||
This is a placeholder for Phase 3 scheduled scanning feature.
|
|
||||||
Currently not used, but structure is in place.
|
|
||||||
"""
|
"""
|
||||||
if not self.scheduler:
|
if not self.scheduler:
|
||||||
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
|
||||||
|
|
||||||
# Parse cron expression
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
# Format: "minute hour day month day_of_week"
|
|
||||||
parts = cron_expression.split()
|
|
||||||
if len(parts) != 5:
|
|
||||||
raise ValueError(f"Invalid cron expression: {cron_expression}")
|
|
||||||
|
|
||||||
minute, hour, day, month, day_of_week = parts
|
# Create cron trigger from expression using local timezone
|
||||||
|
# This allows users to specify times in their local timezone
|
||||||
|
try:
|
||||||
|
trigger = CronTrigger.from_crontab(cron_expression)
|
||||||
|
# timezone defaults to local system timezone
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
|
||||||
|
|
||||||
# Add cron job (currently placeholder - will be enhanced in Phase 3)
|
# Add cron job
|
||||||
job = self.scheduler.add_job(
|
job = self.scheduler.add_job(
|
||||||
func=self._trigger_scheduled_scan,
|
func=self._trigger_scheduled_scan,
|
||||||
args=[schedule_id, config_file],
|
args=[schedule_id],
|
||||||
trigger='cron',
|
trigger=trigger,
|
||||||
minute=minute,
|
|
||||||
hour=hour,
|
|
||||||
day=day,
|
|
||||||
month=month,
|
|
||||||
day_of_week=day_of_week,
|
|
||||||
id=f'schedule_{schedule_id}',
|
id=f'schedule_{schedule_id}',
|
||||||
name=f'Schedule {schedule_id}',
|
name=f'Schedule {schedule_id}',
|
||||||
replace_existing=True
|
replace_existing=True,
|
||||||
|
max_instances=1 # Only one instance per schedule
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})")
|
logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})")
|
||||||
@@ -191,7 +244,7 @@ class SchedulerService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}")
|
logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}")
|
||||||
|
|
||||||
def _trigger_scheduled_scan(self, schedule_id: int, config_file: str):
|
def _trigger_scheduled_scan(self, schedule_id: int):
|
||||||
"""
|
"""
|
||||||
Internal method to trigger a scan from a schedule.
|
Internal method to trigger a scan from a schedule.
|
||||||
|
|
||||||
@@ -199,17 +252,63 @@ class SchedulerService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule_id: Database ID of the schedule
|
schedule_id: Database ID of the schedule
|
||||||
config_file: Path to YAML configuration file
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This will be fully implemented in Phase 3 when scheduled
|
|
||||||
scanning is added. Currently a placeholder.
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}")
|
logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}")
|
||||||
# TODO: In Phase 3, this will:
|
|
||||||
# 1. Create a new Scan record with triggered_by='scheduled'
|
# Import here to avoid circular imports
|
||||||
# 2. Call queue_scan() with the new scan_id
|
from sqlalchemy import create_engine
|
||||||
# 3. Update schedule's last_run and next_run timestamps
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from web.services.schedule_service import ScheduleService
|
||||||
|
from web.services.scan_service import ScanService
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create database session
|
||||||
|
engine = create_engine(self.db_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get schedule details
|
||||||
|
schedule_service = ScheduleService(session)
|
||||||
|
schedule = schedule_service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
logger.error(f"Schedule {schedule_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not schedule['enabled']:
|
||||||
|
logger.warning(f"Schedule {schedule_id} is disabled, skipping execution")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create and trigger scan
|
||||||
|
scan_service = ScanService(session)
|
||||||
|
scan_id = scan_service.trigger_scan(
|
||||||
|
config_file=schedule['config_file'],
|
||||||
|
triggered_by='scheduled',
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
scheduler=None # Don't pass scheduler to avoid recursion
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue the scan for execution
|
||||||
|
self.queue_scan(scan_id, schedule['config_file'])
|
||||||
|
|
||||||
|
# Update schedule's last_run and next_run
|
||||||
|
from croniter import croniter
|
||||||
|
next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime)
|
||||||
|
|
||||||
|
schedule_service.update_run_times(
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
last_run=datetime.utcnow(),
|
||||||
|
next_run=next_run
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Scheduled scan completed: schedule_id={schedule_id}, scan_id={scan_id}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering scheduled scan {schedule_id}: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
def get_job_status(self, job_id: str) -> Optional[dict]:
|
def get_job_status(self, job_id: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
334
web/static/css/styles.css
Normal file
334
web/static/css/styles.css
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
/* CSS Variables */
|
||||||
|
:root {
|
||||||
|
/* Custom Variables */
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
--bg-quaternary: #475569;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border-color: #334155;
|
||||||
|
--accent-blue: #60a5fa;
|
||||||
|
--success-bg: #065f46;
|
||||||
|
--success-text: #6ee7b7;
|
||||||
|
--success-border: #10b981;
|
||||||
|
--warning-bg: #78350f;
|
||||||
|
--warning-text: #fcd34d;
|
||||||
|
--warning-border: #f59e0b;
|
||||||
|
--danger-bg: #7f1d1d;
|
||||||
|
--danger-text: #fca5a5;
|
||||||
|
--danger-border: #ef4444;
|
||||||
|
--info-bg: #1e3a8a;
|
||||||
|
--info-text: #93c5fd;
|
||||||
|
--info-border: #3b82f6;
|
||||||
|
|
||||||
|
/* Bootstrap 5 Variable Overrides for Dark Theme */
|
||||||
|
--bs-body-bg: #0f172a;
|
||||||
|
--bs-body-color: #e2e8f0;
|
||||||
|
--bs-border-color: #334155;
|
||||||
|
--bs-border-color-translucent: rgba(51, 65, 85, 0.5);
|
||||||
|
|
||||||
|
/* Table Variables */
|
||||||
|
--bs-table-bg: #1e293b;
|
||||||
|
--bs-table-color: #e2e8f0;
|
||||||
|
--bs-table-border-color: #334155;
|
||||||
|
--bs-table-striped-bg: #1e293b;
|
||||||
|
--bs-table-striped-color: #e2e8f0;
|
||||||
|
--bs-table-active-bg: #334155;
|
||||||
|
--bs-table-active-color: #e2e8f0;
|
||||||
|
--bs-table-hover-bg: #334155;
|
||||||
|
--bs-table-hover-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar-custom {
|
||||||
|
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||||
|
border-bottom: 1px solid var(--bg-quaternary);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--accent-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.container-fluid {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--bg-quaternary);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-expected,
|
||||||
|
.badge-good,
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-unexpected,
|
||||||
|
.badge-critical,
|
||||||
|
.badge-danger {
|
||||||
|
background-color: var(--danger-bg);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-missing,
|
||||||
|
.badge-warning {
|
||||||
|
background-color: var(--warning-bg);
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: var(--info-bg);
|
||||||
|
color: var(--info-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--bg-quaternary);
|
||||||
|
border-color: var(--bg-quaternary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-bg);
|
||||||
|
border-color: var(--danger-bg);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #991b1b;
|
||||||
|
border-color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables - Fix for dynamically created table rows (white row bug) */
|
||||||
|
.table {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr,
|
||||||
|
.table tbody tr.scan-row {
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: var(--bg-tertiary) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: var(--success-bg);
|
||||||
|
border-color: var(--success-border);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background-color: var(--danger-bg);
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: var(--warning-bg);
|
||||||
|
border-color: var(--warning-border);
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background-color: var(--info-bg);
|
||||||
|
border-color: var(--info-border);
|
||||||
|
color: var(--info-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Controls */
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Cards */
|
||||||
|
.stat-card {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--danger-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-info {
|
||||||
|
color: var(--accent-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner for loading states */
|
||||||
|
.spinner-border-sm {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart.js Dark Theme Styles */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
@@ -4,288 +4,30 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}SneakyScanner{% endblock %}</title>
|
<title>{% block title %}SneakyScanner{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
<!-- Bootstrap Icons -->
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
background-color: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar */
|
<!-- Custom CSS (extracted from inline) -->
|
||||||
.navbar-custom {
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
|
||||||
border-bottom: 1px solid #475569;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
<!-- Chart.js for visualizations -->
|
||||||
font-size: 1.5rem;
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
font-weight: 600;
|
|
||||||
color: #60a5fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
<!-- Chart.js Dark Theme Configuration -->
|
||||||
color: #94a3b8 !important;
|
<script>
|
||||||
transition: color 0.2s;
|
// Configure Chart.js defaults for dark theme
|
||||||
|
if (typeof Chart !== 'undefined') {
|
||||||
|
Chart.defaults.color = '#e2e8f0';
|
||||||
|
Chart.defaults.borderColor = '#334155';
|
||||||
|
Chart.defaults.backgroundColor = '#1e293b';
|
||||||
}
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
.nav-link:hover,
|
{% block extra_styles %}{% endblock %}
|
||||||
.nav-link.active {
|
|
||||||
color: #60a5fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container */
|
|
||||||
.container-fluid {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background-color: #334155;
|
|
||||||
border-bottom: 1px solid #475569;
|
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 12px 12px 0 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
color: #60a5fa;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badges */
|
|
||||||
.badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-expected,
|
|
||||||
.badge-good,
|
|
||||||
.badge-success {
|
|
||||||
background-color: #065f46;
|
|
||||||
color: #6ee7b7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-unexpected,
|
|
||||||
.badge-critical,
|
|
||||||
.badge-danger {
|
|
||||||
background-color: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-missing,
|
|
||||||
.badge-warning {
|
|
||||||
background-color: #78350f;
|
|
||||||
color: #fcd34d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-info {
|
|
||||||
background-color: #1e3a8a;
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #3b82f6;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #2563eb;
|
|
||||||
border-color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #334155;
|
|
||||||
border-color: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: #475569;
|
|
||||||
border-color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: #7f1d1d;
|
|
||||||
border-color: #7f1d1d;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background-color: #991b1b;
|
|
||||||
border-color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
.table {
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table thead {
|
|
||||||
background-color: #334155;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border-color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background-color: #334155;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th,
|
|
||||||
.table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alerts */
|
|
||||||
.alert {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background-color: #065f46;
|
|
||||||
border-color: #10b981;
|
|
||||||
color: #6ee7b7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background-color: #7f1d1d;
|
|
||||||
border-color: #ef4444;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-warning {
|
|
||||||
background-color: #78350f;
|
|
||||||
border-color: #f59e0b;
|
|
||||||
color: #fcd34d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-info {
|
|
||||||
background-color: #1e3a8a;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Controls */
|
|
||||||
.form-control,
|
|
||||||
.form-select {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border-color: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus,
|
|
||||||
.form-select:focus {
|
|
||||||
background-color: #1e293b;
|
|
||||||
border-color: #60a5fa;
|
|
||||||
color: #e2e8f0;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Cards */
|
|
||||||
.stat-card {
|
|
||||||
background-color: #0f172a;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer {
|
|
||||||
margin-top: 40px;
|
|
||||||
padding: 20px 0;
|
|
||||||
border-top: 1px solid #334155;
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utilities */
|
|
||||||
.text-muted {
|
|
||||||
color: #94a3b8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-warning {
|
|
||||||
color: #f59e0b !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-info {
|
|
||||||
color: #60a5fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spinner for loading states */
|
|
||||||
.spinner-border-sm {
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
{% block extra_styles %}{% endblock %}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% if not hide_nav %}
|
{% if not hide_nav %}
|
||||||
@@ -307,6 +49,10 @@
|
|||||||
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
|
||||||
href="{{ url_for('main.scans') }}">Scans</a>
|
href="{{ url_for('main.scans') }}">Scans</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
||||||
|
href="{{ url_for('main.schedules') }}">Schedules</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -335,7 +81,7 @@
|
|||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
SneakyScanner v1.0 - Phase 2 Complete
|
SneakyScanner v1.0 - Phase 3 In Progress
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,51 @@
|
|||||||
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
|
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
|
||||||
|
<i class="bi bi-calendar-plus"></i> Manage Schedules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scan Activity Chart -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="chart-loading" class="text-center py-4">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules Widget -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="schedules-loading" class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="schedules-content" style="display: none;"></div>
|
||||||
|
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
|
||||||
|
No schedules configured yet.
|
||||||
|
<br><br>
|
||||||
|
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,13 +154,19 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<input type="text"
|
<select class="form-select" id="config-file" name="config_file" required>
|
||||||
class="form-control"
|
<option value="">Select a config file...</option>
|
||||||
id="config-file"
|
{% for config in config_files %}
|
||||||
name="config_file"
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
placeholder="/app/configs/example.yaml"
|
{% endfor %}
|
||||||
required>
|
</select>
|
||||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
<div class="form-text text-muted">
|
||||||
|
{% if config_files %}
|
||||||
|
Select a scan configuration file
|
||||||
|
{% else %}
|
||||||
|
<span class="text-warning">No config files found in /app/configs/</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -140,6 +191,8 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
refreshScans();
|
refreshScans();
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadScanTrend();
|
||||||
|
loadSchedules();
|
||||||
|
|
||||||
// Auto-refresh every 10 seconds if there are running scans
|
// Auto-refresh every 10 seconds if there are running scans
|
||||||
refreshInterval = setInterval(function() {
|
refreshInterval = setInterval(function() {
|
||||||
@@ -149,6 +202,9 @@
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
// Refresh schedules every 30 seconds
|
||||||
|
setInterval(loadSchedules, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load dashboard stats
|
// Load dashboard stats
|
||||||
@@ -224,6 +280,7 @@
|
|||||||
|
|
||||||
scans.forEach(scan => {
|
scans.forEach(scan => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row'); // Fix white row bug
|
||||||
|
|
||||||
// Format timestamp
|
// Format timestamp
|
||||||
const timestamp = new Date(scan.timestamp).toLocaleString();
|
const timestamp = new Date(scan.timestamp).toLocaleString();
|
||||||
@@ -298,7 +355,7 @@
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to trigger scan');
|
throw new Error(data.message || data.error || 'Failed to trigger scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -328,6 +385,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load scan trend chart
|
||||||
|
async function loadScanTrend() {
|
||||||
|
const chartLoading = document.getElementById('chart-loading');
|
||||||
|
const canvas = document.getElementById('scanTrendChart');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats/scan-trend?days=30');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load trend data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Hide loading, show chart
|
||||||
|
chartLoading.style.display = 'none';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
|
||||||
|
// Create chart
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Scans per Day',
|
||||||
|
data: data.values,
|
||||||
|
borderColor: '#60a5fa',
|
||||||
|
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function(context) {
|
||||||
|
return new Date(context[0].label).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 10
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading chart:', error);
|
||||||
|
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load upcoming schedules
|
||||||
|
async function loadSchedules() {
|
||||||
|
const loadingEl = document.getElementById('schedules-loading');
|
||||||
|
const contentEl = document.getElementById('schedules-content');
|
||||||
|
const emptyEl = document.getElementById('schedules-empty');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/schedules?per_page=5');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const schedules = data.schedules || [];
|
||||||
|
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
emptyEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
contentEl.style.display = 'block';
|
||||||
|
|
||||||
|
// Filter enabled schedules and sort by next_run
|
||||||
|
const enabledSchedules = schedules
|
||||||
|
.filter(s => s.enabled && s.next_run)
|
||||||
|
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (enabledSchedules.length === 0) {
|
||||||
|
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = enabledSchedules.map(schedule => {
|
||||||
|
const nextRun = new Date(schedule.next_run);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = nextRun - now;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
let timeStr;
|
||||||
|
if (diffMins < 1) {
|
||||||
|
timeStr = 'In less than 1 minute';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
timeStr = nextRun.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mb-3 pb-3 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>${schedule.name}</strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">${timeStr}</small>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted mono">${schedule.cron_expression}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
contentEl.style.display = 'block';
|
||||||
|
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete scan
|
// Delete scan
|
||||||
async function deleteScan(scanId) {
|
async function deleteScan(scanId) {
|
||||||
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
||||||
|
|||||||
526
web/templates/scan_compare.html
Normal file
526
web/templates/scan_compare.html
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Compare Scans - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
|
||||||
|
← Back to All Scans
|
||||||
|
</a>
|
||||||
|
<h1 style="color: #60a5fa;">Scan Comparison</h1>
|
||||||
|
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="comparison-loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Loading comparison...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Comparison Content -->
|
||||||
|
<div id="comparison-content" style="display: none;">
|
||||||
|
<!-- Drift Score Card -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
|
||||||
|
<div class="text-muted">Drift Score</div>
|
||||||
|
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
|
||||||
|
<div id="scan1-title" class="fw-bold">-</div>
|
||||||
|
<small class="text-muted" id="scan1-timestamp">-</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
|
||||||
|
<div id="scan2-title" class="fw-bold">-</div>
|
||||||
|
<small class="text-muted" id="scan2-timestamp">-</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label text-muted">Quick Actions</label>
|
||||||
|
<div>
|
||||||
|
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
|
||||||
|
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ports Comparison -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-hdd-network"></i> Port Changes
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||||
|
<div class="stat-value" id="ports-added-count">0</div>
|
||||||
|
<div class="stat-label">Ports Added</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||||
|
<div class="stat-value" id="ports-removed-count">0</div>
|
||||||
|
<div class="stat-label">Ports Removed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="ports-unchanged-count">0</div>
|
||||||
|
<div class="stat-label">Ports Unchanged</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Added Ports -->
|
||||||
|
<div id="ports-added-section" style="display: none;">
|
||||||
|
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ports-added-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Removed Ports -->
|
||||||
|
<div id="ports-removed-section" style="display: none;">
|
||||||
|
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ports-removed-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services Comparison -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-gear"></i> Service Changes
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||||
|
<div class="stat-value" id="services-added-count">0</div>
|
||||||
|
<div class="stat-label">Services Added</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||||
|
<div class="stat-value" id="services-removed-count">0</div>
|
||||||
|
<div class="stat-label">Services Removed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
|
||||||
|
<div class="stat-value" id="services-changed-count">0</div>
|
||||||
|
<div class="stat-label">Services Changed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Changed Services -->
|
||||||
|
<div id="services-changed-section" style="display: none;">
|
||||||
|
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP:Port</th>
|
||||||
|
<th>Old Service</th>
|
||||||
|
<th>New Service</th>
|
||||||
|
<th>Old Version</th>
|
||||||
|
<th>New Version</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="services-changed-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Added Services -->
|
||||||
|
<div id="services-added-section" style="display: none;">
|
||||||
|
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Version</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="services-added-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Removed Services -->
|
||||||
|
<div id="services-removed-section" style="display: none;">
|
||||||
|
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Version</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="services-removed-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Certificates Comparison -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-shield-lock"></i> Certificate Changes
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
|
||||||
|
<div class="stat-value" id="certs-added-count">0</div>
|
||||||
|
<div class="stat-label">Certificates Added</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
|
||||||
|
<div class="stat-value" id="certs-removed-count">0</div>
|
||||||
|
<div class="stat-label">Certificates Removed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
|
||||||
|
<div class="stat-value" id="certs-changed-count">0</div>
|
||||||
|
<div class="stat-label">Certificates Changed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Changed Certificates -->
|
||||||
|
<div id="certs-changed-section" style="display: none;">
|
||||||
|
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP:Port</th>
|
||||||
|
<th>Old Subject</th>
|
||||||
|
<th>New Subject</th>
|
||||||
|
<th>Old Expiry</th>
|
||||||
|
<th>New Expiry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="certs-changed-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Added/Removed Certificates (shown if any) -->
|
||||||
|
<div id="certs-added-removed-info" style="display: none;">
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Additional certificate additions and removals correspond to the port changes shown above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const scanId1 = {{ scan_id1 }};
|
||||||
|
const scanId2 = {{ scan_id2 }};
|
||||||
|
|
||||||
|
// Load comparison data
|
||||||
|
async function loadComparison() {
|
||||||
|
const loadingDiv = document.getElementById('comparison-loading');
|
||||||
|
const errorDiv = document.getElementById('comparison-error');
|
||||||
|
const contentDiv = document.getElementById('comparison-content');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load comparison');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Hide loading, show content
|
||||||
|
loadingDiv.style.display = 'none';
|
||||||
|
contentDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Populate comparison UI
|
||||||
|
populateComparison(data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading comparison:', error);
|
||||||
|
loadingDiv.style.display = 'none';
|
||||||
|
errorDiv.textContent = `Error: ${error.message}`;
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateComparison(data) {
|
||||||
|
// Drift score
|
||||||
|
const driftScore = data.drift_score || 0;
|
||||||
|
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
|
||||||
|
|
||||||
|
// Color code drift score
|
||||||
|
const driftElement = document.getElementById('drift-score');
|
||||||
|
if (driftScore < 0.1) {
|
||||||
|
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
|
||||||
|
} else if (driftScore < 0.3) {
|
||||||
|
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
|
||||||
|
} else {
|
||||||
|
driftElement.style.color = '#fca5a5'; // Red - significant drift
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan metadata
|
||||||
|
document.getElementById('scan1-id').textContent = data.scan1.id;
|
||||||
|
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
||||||
|
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
||||||
|
|
||||||
|
document.getElementById('scan2-id').textContent = data.scan2.id;
|
||||||
|
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
||||||
|
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
||||||
|
|
||||||
|
// Ports comparison
|
||||||
|
populatePortsComparison(data.ports);
|
||||||
|
|
||||||
|
// Services comparison
|
||||||
|
populateServicesComparison(data.services);
|
||||||
|
|
||||||
|
// Certificates comparison
|
||||||
|
populateCertificatesComparison(data.certificates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function populatePortsComparison(ports) {
|
||||||
|
const addedCount = ports.added.length;
|
||||||
|
const removedCount = ports.removed.length;
|
||||||
|
const unchangedCount = ports.unchanged.length;
|
||||||
|
|
||||||
|
document.getElementById('ports-added-count').textContent = addedCount;
|
||||||
|
document.getElementById('ports-removed-count').textContent = removedCount;
|
||||||
|
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
|
||||||
|
|
||||||
|
// Show added ports
|
||||||
|
if (addedCount > 0) {
|
||||||
|
document.getElementById('ports-added-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('ports-added-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
ports.added.forEach(port => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${port.ip}</td>
|
||||||
|
<td class="mono">${port.port}</td>
|
||||||
|
<td>${port.protocol.toUpperCase()}</td>
|
||||||
|
<td>${port.state}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show removed ports
|
||||||
|
if (removedCount > 0) {
|
||||||
|
document.getElementById('ports-removed-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('ports-removed-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
ports.removed.forEach(port => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${port.ip}</td>
|
||||||
|
<td class="mono">${port.port}</td>
|
||||||
|
<td>${port.protocol.toUpperCase()}</td>
|
||||||
|
<td>${port.state}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateServicesComparison(services) {
|
||||||
|
const addedCount = services.added.length;
|
||||||
|
const removedCount = services.removed.length;
|
||||||
|
const changedCount = services.changed.length;
|
||||||
|
|
||||||
|
document.getElementById('services-added-count').textContent = addedCount;
|
||||||
|
document.getElementById('services-removed-count').textContent = removedCount;
|
||||||
|
document.getElementById('services-changed-count').textContent = changedCount;
|
||||||
|
|
||||||
|
// Show changed services
|
||||||
|
if (changedCount > 0) {
|
||||||
|
document.getElementById('services-changed-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('services-changed-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
services.changed.forEach(svc => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono">${svc.ip}:${svc.port}</td>
|
||||||
|
<td>${svc.old.service_name || '-'}</td>
|
||||||
|
<td class="text-warning">${svc.new.service_name || '-'}</td>
|
||||||
|
<td>${svc.old.version || '-'}</td>
|
||||||
|
<td class="text-warning">${svc.new.version || '-'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show added services
|
||||||
|
if (addedCount > 0) {
|
||||||
|
document.getElementById('services-added-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('services-added-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
services.added.forEach(svc => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${svc.ip}</td>
|
||||||
|
<td class="mono">${svc.port}</td>
|
||||||
|
<td>${svc.service_name || '-'}</td>
|
||||||
|
<td>${svc.product || '-'}</td>
|
||||||
|
<td>${svc.version || '-'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show removed services
|
||||||
|
if (removedCount > 0) {
|
||||||
|
document.getElementById('services-removed-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('services-removed-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
services.removed.forEach(svc => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${svc.ip}</td>
|
||||||
|
<td class="mono">${svc.port}</td>
|
||||||
|
<td>${svc.service_name || '-'}</td>
|
||||||
|
<td>${svc.product || '-'}</td>
|
||||||
|
<td>${svc.version || '-'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCertificatesComparison(certs) {
|
||||||
|
const addedCount = certs.added.length;
|
||||||
|
const removedCount = certs.removed.length;
|
||||||
|
const changedCount = certs.changed.length;
|
||||||
|
|
||||||
|
document.getElementById('certs-added-count').textContent = addedCount;
|
||||||
|
document.getElementById('certs-removed-count').textContent = removedCount;
|
||||||
|
document.getElementById('certs-changed-count').textContent = changedCount;
|
||||||
|
|
||||||
|
// Show changed certificates
|
||||||
|
if (changedCount > 0) {
|
||||||
|
document.getElementById('certs-changed-section').style.display = 'block';
|
||||||
|
const tbody = document.getElementById('certs-changed-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
certs.changed.forEach(cert => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono">${cert.ip}:${cert.port}</td>
|
||||||
|
<td>${cert.old.subject || '-'}</td>
|
||||||
|
<td class="text-warning">${cert.new.subject || '-'}</td>
|
||||||
|
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
|
||||||
|
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show info if there are added/removed certs
|
||||||
|
if (addedCount > 0 || removedCount > 0) {
|
||||||
|
document.getElementById('certs-added-removed-info').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load comparison on page load
|
||||||
|
loadComparison();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-secondary" onclick="refreshScan()">
|
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> Compare with Previous
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
|
||||||
<span id="refresh-text">Refresh</span>
|
<span id="refresh-text">Refresh</span>
|
||||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -117,6 +120,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Historical Trend Chart -->
|
||||||
|
<div class="row mb-4" id="historical-chart-row" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-graph-up"></i> Port Count History
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Historical port count trend for scans using the same configuration
|
||||||
|
</p>
|
||||||
|
<canvas id="historyChart" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sites and IPs -->
|
<!-- Sites and IPs -->
|
||||||
<div id="sites-container">
|
<div id="sites-container">
|
||||||
<!-- Sites will be dynamically inserted here -->
|
<!-- Sites will be dynamically inserted here -->
|
||||||
@@ -306,12 +328,13 @@
|
|||||||
const ports = ip.ports || [];
|
const ports = ip.ports || [];
|
||||||
|
|
||||||
if (ports.length === 0) {
|
if (ports.length === 0) {
|
||||||
portsContainer.innerHTML = '<tr><td colspan="7" class="text-center text-muted">No ports found</td></tr>';
|
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="7" class="text-center text-muted">No ports found</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
ports.forEach(port => {
|
ports.forEach(port => {
|
||||||
const service = port.services && port.services.length > 0 ? port.services[0] : null;
|
const service = port.services && port.services.length > 0 ? port.services[0] : null;
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row'); // Fix white row bug
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td class="mono">${port.port}</td>
|
<td class="mono">${port.port}</td>
|
||||||
<td>${port.protocol.toUpperCase()}</td>
|
<td>${port.protocol.toUpperCase()}</td>
|
||||||
@@ -378,21 +401,180 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable delete button to prevent double-clicks
|
||||||
|
const deleteBtn = document.getElementById('delete-btn');
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.textContent = 'Deleting...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/scans/${scanId}`, {
|
const response = await fetch(`/api/scans/${scanId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check status code first
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete scan');
|
// Try to get error message from response
|
||||||
|
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
errorMessage = data.message || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore JSON parse errors for error responses
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For successful responses, try to parse JSON but don't fail if it doesn't work
|
||||||
|
try {
|
||||||
|
await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Response is not valid JSON, but deletion succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 2 seconds to ensure deletion completes fully
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
// Redirect to scans list
|
// Redirect to scans list
|
||||||
window.location.href = '{{ url_for("main.scans") }}';
|
window.location.href = '{{ url_for("main.scans") }}';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting scan:', error);
|
console.error('Error deleting scan:', error);
|
||||||
alert('Failed to delete scan. Please try again.');
|
alert(`Failed to delete scan: ${error.message}`);
|
||||||
|
|
||||||
|
// Re-enable button on error
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.textContent = 'Delete Scan';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find previous scan and show compare button
|
||||||
|
let previousScanId = null;
|
||||||
|
async function findPreviousScan() {
|
||||||
|
try {
|
||||||
|
// Get list of scans to find the previous one
|
||||||
|
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.scans && data.scans.length > 0) {
|
||||||
|
// Find scans older than current scan
|
||||||
|
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||||
|
|
||||||
|
if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) {
|
||||||
|
// Get the next scan in the list (which is older due to desc order)
|
||||||
|
previousScanId = data.scans[currentScanIndex + 1].id;
|
||||||
|
|
||||||
|
// Show the compare button
|
||||||
|
const compareBtn = document.getElementById('compare-btn');
|
||||||
|
if (compareBtn) {
|
||||||
|
compareBtn.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding previous scan:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with previous scan
|
||||||
|
function compareWithPrevious() {
|
||||||
|
if (previousScanId) {
|
||||||
|
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load historical trend chart
|
||||||
|
async function loadHistoricalChart() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Only show chart if there are multiple scans
|
||||||
|
if (data.scans && data.scans.length > 1) {
|
||||||
|
document.getElementById('historical-chart-row').style.display = 'block';
|
||||||
|
|
||||||
|
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Open Ports',
|
||||||
|
data: data.port_counts,
|
||||||
|
borderColor: '#60a5fa',
|
||||||
|
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointBackgroundColor: '#60a5fa',
|
||||||
|
pointBorderColor: '#1e293b',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
titleColor: '#e2e8f0',
|
||||||
|
bodyColor: '#e2e8f0',
|
||||||
|
borderColor: '#334155',
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
afterLabel: function(context) {
|
||||||
|
const scan = data.scans[context.dataIndex];
|
||||||
|
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1,
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 45
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: '#334155'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick: (event, elements) => {
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const index = elements[0].index;
|
||||||
|
const scan = data.scans[index];
|
||||||
|
window.location.href = `/scans/${scan.id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading historical chart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize: find previous scan and load chart after loading current scan
|
||||||
|
loadScan().then(() => {
|
||||||
|
findPreviousScan();
|
||||||
|
loadHistoricalChart();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -114,13 +114,19 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<input type="text"
|
<select class="form-select" id="config-file" name="config_file" required>
|
||||||
class="form-control"
|
<option value="">Select a config file...</option>
|
||||||
id="config-file"
|
{% for config in config_files %}
|
||||||
name="config_file"
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
placeholder="/app/configs/example.yaml"
|
{% endfor %}
|
||||||
required>
|
</select>
|
||||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
<div class="form-text text-muted">
|
||||||
|
{% if config_files %}
|
||||||
|
Select a scan configuration file
|
||||||
|
{% else %}
|
||||||
|
<span class="text-warning">No config files found in /app/configs/</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -206,6 +212,7 @@
|
|||||||
|
|
||||||
scans.forEach(scan => {
|
scans.forEach(scan => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('scan-row'); // Fix white row bug
|
||||||
|
|
||||||
// Format timestamp
|
// Format timestamp
|
||||||
const timestamp = new Date(scan.timestamp).toLocaleString();
|
const timestamp = new Date(scan.timestamp).toLocaleString();
|
||||||
|
|||||||
441
web/templates/schedule_create.html
Normal file
441
web/templates/schedule_create.html
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create Schedule - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Create Schedule</h1>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Schedules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<form id="create-schedule-form">
|
||||||
|
<!-- Basic Information Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Schedule Name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="schedule-name" name="name"
|
||||||
|
placeholder="e.g., Daily Infrastructure Scan"
|
||||||
|
required>
|
||||||
|
<small class="form-text text-muted">A descriptive name for this schedule</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config File -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="config-file" name="config_file" required>
|
||||||
|
<option value="">Select a configuration file...</option>
|
||||||
|
{% for config in config_files %}
|
||||||
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable/Disable -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="schedule-enabled"
|
||||||
|
name="enabled" checked>
|
||||||
|
<label class="form-check-label" for="schedule-enabled">
|
||||||
|
Enable schedule immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">If disabled, the schedule will be created but not executed</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron Expression Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Quick Templates -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Quick Templates:</label>
|
||||||
|
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||||
|
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||||
|
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||||
|
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
|
||||||
|
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
|
||||||
|
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Cron Entry -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="cron-expression" class="form-label">
|
||||||
|
Cron Expression <span class="text-danger">*</span>
|
||||||
|
<span class="badge bg-info">LOCAL TIME</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="cron-expression"
|
||||||
|
name="cron_expression" placeholder="0 2 * * *"
|
||||||
|
oninput="validateCron()" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Format: <code>minute hour day month weekday</code><br>
|
||||||
|
<strong class="text-info">ℹ All times use your local timezone (CST/UTC-6)</strong>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron Validation Feedback -->
|
||||||
|
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Human-Readable Description -->
|
||||||
|
<div id="cron-description-container" style="display: none;">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Description:</strong>
|
||||||
|
<div id="cron-description" class="mt-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Run Times Preview -->
|
||||||
|
<div id="next-runs-container" style="display: none;">
|
||||||
|
<label class="form-label">Next 5 execution times (local time):</label>
|
||||||
|
<ul id="next-runs-list" class="list-group">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card sticky-top" style="top: 20px;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Field Format:</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Values</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Minute</td>
|
||||||
|
<td>0-59</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hour</td>
|
||||||
|
<td>0-23</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Day</td>
|
||||||
|
<td>1-31</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Month</td>
|
||||||
|
<td>1-12</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Weekday</td>
|
||||||
|
<td>0-6 (0=Sunday)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Special Characters:</h6>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><code>*</code> - Any value</li>
|
||||||
|
<li><code>*/n</code> - Every n units</li>
|
||||||
|
<li><code>1,2,3</code> - Specific values</li>
|
||||||
|
<li><code>1-5</code> - Range of values</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Examples:</h6>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><code>0 0 * * *</code> - Daily at midnight</li>
|
||||||
|
<li><code>*/15 * * * *</code> - Every 15 minutes</li>
|
||||||
|
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||||
|
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||||
|
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
|
||||||
|
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
|
||||||
|
<small>Schedules will run at the specified time in your local timezone.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update local time and timezone info every second
|
||||||
|
function updateServerTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const localTime = now.toLocaleTimeString();
|
||||||
|
const offset = -now.getTimezoneOffset() / 60;
|
||||||
|
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||||
|
|
||||||
|
if (document.getElementById('user-local-time')) {
|
||||||
|
document.getElementById('user-local-time').textContent = localTime;
|
||||||
|
}
|
||||||
|
if (document.getElementById('timezone-offset')) {
|
||||||
|
document.getElementById('timezone-offset').textContent = offsetStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateServerTime();
|
||||||
|
setInterval(updateServerTime, 1000);
|
||||||
|
|
||||||
|
// Set cron expression from template button
|
||||||
|
function setCron(expression) {
|
||||||
|
document.getElementById('cron-expression').value = expression;
|
||||||
|
validateCron();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron expression (client-side basic validation)
|
||||||
|
function validateCron() {
|
||||||
|
const input = document.getElementById('cron-expression');
|
||||||
|
const expression = input.value.trim();
|
||||||
|
const feedback = document.getElementById('cron-feedback');
|
||||||
|
const descContainer = document.getElementById('cron-description-container');
|
||||||
|
const description = document.getElementById('cron-description');
|
||||||
|
const nextRunsContainer = document.getElementById('next-runs-container');
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
feedback.style.display = 'none';
|
||||||
|
descContainer.style.display = 'none';
|
||||||
|
nextRunsContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation: should have 5 fields
|
||||||
|
const parts = expression.split(/\s+/);
|
||||||
|
if (parts.length !== 5) {
|
||||||
|
feedback.className = 'alert alert-danger';
|
||||||
|
feedback.textContent = 'Invalid format: Cron expression must have exactly 5 fields (minute hour day month weekday)';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
descContainer.style.display = 'none';
|
||||||
|
nextRunsContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic field validation
|
||||||
|
const [minute, hour, day, month, weekday] = parts;
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!isValidCronField(minute, 0, 59)) errors.push('minute (0-59)');
|
||||||
|
if (!isValidCronField(hour, 0, 23)) errors.push('hour (0-23)');
|
||||||
|
if (!isValidCronField(day, 1, 31)) errors.push('day (1-31)');
|
||||||
|
if (!isValidCronField(month, 1, 12)) errors.push('month (1-12)');
|
||||||
|
if (!isValidCronField(weekday, 0, 6)) errors.push('weekday (0-6)');
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
feedback.className = 'alert alert-danger';
|
||||||
|
feedback.textContent = 'Invalid fields: ' + errors.join(', ');
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
descContainer.style.display = 'none';
|
||||||
|
nextRunsContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid expression
|
||||||
|
feedback.className = 'alert alert-success';
|
||||||
|
feedback.textContent = 'Valid cron expression';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
|
||||||
|
// Show human-readable description
|
||||||
|
description.textContent = describeCron(parts);
|
||||||
|
descContainer.style.display = 'block';
|
||||||
|
|
||||||
|
// Calculate and show next run times
|
||||||
|
calculateNextRuns(expression);
|
||||||
|
nextRunsContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate individual cron field
|
||||||
|
function isValidCronField(field, min, max) {
|
||||||
|
if (field === '*') return true;
|
||||||
|
|
||||||
|
// Handle ranges: 1-5
|
||||||
|
if (field.includes('-')) {
|
||||||
|
const [start, end] = field.split('-').map(Number);
|
||||||
|
return start >= min && end <= max && start <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle steps: */5 or 1-10/2
|
||||||
|
if (field.includes('/')) {
|
||||||
|
const [range, step] = field.split('/');
|
||||||
|
if (range === '*') return Number(step) > 0;
|
||||||
|
return isValidCronField(range, min, max) && Number(step) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lists: 1,2,3
|
||||||
|
if (field.includes(',')) {
|
||||||
|
return field.split(',').every(v => {
|
||||||
|
const num = Number(v);
|
||||||
|
return !isNaN(num) && num >= min && num <= max;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single number
|
||||||
|
const num = Number(field);
|
||||||
|
return !isNaN(num) && num >= min && num <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate human-readable description
|
||||||
|
function describeCron(parts) {
|
||||||
|
const [minute, hour, day, month, weekday] = parts;
|
||||||
|
|
||||||
|
// Common patterns
|
||||||
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return 'Runs daily at midnight (local time)';
|
||||||
|
}
|
||||||
|
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
|
||||||
|
}
|
||||||
|
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
|
||||||
|
}
|
||||||
|
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
|
||||||
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
return `Runs weekly on ${days[Number(weekday)]} at midnight`;
|
||||||
|
}
|
||||||
|
if (minute === '0' && hour === '0' && day !== '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Runs monthly on day ${day} at midnight`;
|
||||||
|
}
|
||||||
|
if (minute.startsWith('*/')) {
|
||||||
|
const interval = minute.split('/')[1];
|
||||||
|
return `Runs every ${interval} minutes`;
|
||||||
|
}
|
||||||
|
if (hour.startsWith('*/') && minute === '0') {
|
||||||
|
const interval = hour.split('/')[1];
|
||||||
|
return `Runs every ${interval} hours`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Runs at ${minute} ${hour} ${day} ${month} ${weekday} (cron format)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next 5 run times (simplified - server will do actual calculation)
|
||||||
|
function calculateNextRuns(expression) {
|
||||||
|
const list = document.getElementById('next-runs-list');
|
||||||
|
list.innerHTML = '<li class="list-group-item"><em>Will be calculated by server...</em></li>';
|
||||||
|
|
||||||
|
// In production, this would call an API endpoint to get accurate next runs
|
||||||
|
// For now, just show placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('create-schedule-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
const formData = {
|
||||||
|
name: document.getElementById('schedule-name').value.trim(),
|
||||||
|
config_file: document.getElementById('config-file').value,
|
||||||
|
cron_expression: document.getElementById('cron-expression').value.trim(),
|
||||||
|
enabled: document.getElementById('schedule-enabled').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!formData.name || !formData.config_file || !formData.cron_expression) {
|
||||||
|
showNotification('Please fill in all required fields', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable submit button
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
showNotification('Schedule created successfully! Redirecting...', 'success');
|
||||||
|
|
||||||
|
// Redirect to schedules list
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/schedules';
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
|
||||||
|
// Re-enable submit button
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||||
|
notification.style.top = '20px';
|
||||||
|
notification.style.right = '20px';
|
||||||
|
notification.style.zIndex = '9999';
|
||||||
|
notification.style.minWidth = '300px';
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
595
web/templates/schedule_edit.html
Normal file
595
web/templates/schedule_edit.html
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit Schedule - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Edit Schedule</h1>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Schedules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Loading schedule...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="error-state" style="display: none;" class="alert alert-danger">
|
||||||
|
<strong>Error:</strong> <span id="error-message"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Form -->
|
||||||
|
<form id="edit-schedule-form" style="display: none;">
|
||||||
|
<input type="hidden" id="schedule-id">
|
||||||
|
|
||||||
|
<!-- Basic Information Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Schedule Name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="schedule-name" name="name"
|
||||||
|
placeholder="e.g., Daily Infrastructure Scan"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config File (read-only) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="config-file" class="form-label">Configuration File</label>
|
||||||
|
<input type="text" class="form-control" id="config-file" readonly>
|
||||||
|
<small class="form-text text-muted">Configuration file cannot be changed after creation</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable/Disable -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="schedule-enabled"
|
||||||
|
name="enabled">
|
||||||
|
<label class="form-check-label" for="schedule-enabled">
|
||||||
|
Schedule enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">
|
||||||
|
<strong>Created:</strong> <span id="created-at">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<small class="text-muted">
|
||||||
|
<strong>Last Modified:</strong> <span id="updated-at">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron Expression Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Quick Templates -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Quick Templates:</label>
|
||||||
|
<div class="btn-group-vertical btn-group-sm w-100" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
|
||||||
|
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
|
||||||
|
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
|
||||||
|
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
|
||||||
|
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
|
||||||
|
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Cron Entry -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="cron-expression" class="form-label">
|
||||||
|
Cron Expression <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="cron-expression"
|
||||||
|
name="cron_expression" placeholder="0 2 * * *"
|
||||||
|
oninput="validateCron()" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Format: <code>minute hour day month weekday</code> (local timezone)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron Validation Feedback -->
|
||||||
|
<div id="cron-feedback" class="alert" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Run Times Info -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Last Run:</strong><br>
|
||||||
|
<span id="last-run" style="white-space: pre-line;">Never</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Next Run:</strong><br>
|
||||||
|
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Execution History Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Execution History</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="history-loading" class="text-center py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
<span class="ms-2 text-muted">Loading history...</span>
|
||||||
|
</div>
|
||||||
|
<div id="history-content" style="display: none;">
|
||||||
|
<p class="text-muted">Last 10 scans triggered by this schedule:</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Scan ID</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-tbody">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="history-empty" style="display: none;" class="text-center py-3 text-muted">
|
||||||
|
No executions yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="deleteSchedule()">
|
||||||
|
<i class="bi bi-trash"></i> Delete Schedule
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="testRun()">
|
||||||
|
<i class="bi bi-play-fill"></i> Test Run Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary me-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||||
|
<i class="bi bi-check-circle"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card sticky-top" style="top: 20px;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Field Format:</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Values</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Minute</td>
|
||||||
|
<td>0-59</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Hour</td>
|
||||||
|
<td>0-23</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Day</td>
|
||||||
|
<td>1-31</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Month</td>
|
||||||
|
<td>1-12</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Weekday</td>
|
||||||
|
<td>0-6 (0=Sunday)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Special Characters:</h6>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><code>*</code> - Any value</li>
|
||||||
|
<li><code>*/n</code> - Every n units</li>
|
||||||
|
<li><code>1,2,3</code> - Specific values</li>
|
||||||
|
<li><code>1-5</code> - Range of values</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
|
||||||
|
All cron expressions use your <strong>local system time</strong>.<br><br>
|
||||||
|
<strong>Current local time:</strong> <span id="current-local"></span><br>
|
||||||
|
<strong>Your timezone:</strong> <span id="tz-offset"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let scheduleData = null;
|
||||||
|
|
||||||
|
// Get schedule ID from URL
|
||||||
|
const scheduleId = parseInt(window.location.pathname.split('/')[2]);
|
||||||
|
|
||||||
|
// Load schedule data
|
||||||
|
async function loadSchedule() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleData = await response.json();
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
populateForm(scheduleData);
|
||||||
|
|
||||||
|
// Load execution history
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
// Hide loading, show form
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('edit-schedule-form').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedule:', error);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('error-state').style.display = 'block';
|
||||||
|
document.getElementById('error-message').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate form with schedule data
|
||||||
|
function populateForm(schedule) {
|
||||||
|
document.getElementById('schedule-id').value = schedule.id;
|
||||||
|
document.getElementById('schedule-name').value = schedule.name;
|
||||||
|
document.getElementById('config-file').value = schedule.config_file;
|
||||||
|
document.getElementById('cron-expression').value = schedule.cron_expression;
|
||||||
|
document.getElementById('schedule-enabled').checked = schedule.enabled;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
|
||||||
|
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
|
||||||
|
|
||||||
|
// Run times - show in local time
|
||||||
|
document.getElementById('last-run').textContent = schedule.last_run
|
||||||
|
? formatRelativeTime(schedule.last_run) + '\n' +
|
||||||
|
new Date(schedule.last_run).toLocaleString()
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
||||||
|
? formatRelativeTime(schedule.next_run) + '\n' +
|
||||||
|
new Date(schedule.next_run).toLocaleString()
|
||||||
|
: (schedule.enabled ? 'Calculating...' : 'Disabled');
|
||||||
|
|
||||||
|
// Validate cron
|
||||||
|
validateCron();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load execution history
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
// Note: This would ideally be a separate API endpoint
|
||||||
|
// For now, we'll fetch scans filtered by schedule_id
|
||||||
|
const response = await fetch(`/api/scans?schedule_id=${scheduleId}&limit=10`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const scans = data.scans || [];
|
||||||
|
|
||||||
|
renderHistory(scans);
|
||||||
|
|
||||||
|
document.getElementById('history-loading').style.display = 'none';
|
||||||
|
document.getElementById('history-content').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading history:', error);
|
||||||
|
document.getElementById('history-loading').innerHTML = '<p class="text-danger">Failed to load history</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render history table
|
||||||
|
function renderHistory(scans) {
|
||||||
|
const tbody = document.getElementById('history-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (scans.length === 0) {
|
||||||
|
document.querySelector('#history-content .table-responsive').style.display = 'none';
|
||||||
|
document.getElementById('history-empty').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#history-content .table-responsive').style.display = 'block';
|
||||||
|
document.getElementById('history-empty').style.display = 'none';
|
||||||
|
|
||||||
|
scans.forEach(scan => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('schedule-row');
|
||||||
|
row.style.cursor = 'pointer';
|
||||||
|
row.onclick = () => window.location.href = `/scans/${scan.id}`;
|
||||||
|
|
||||||
|
const duration = scan.end_time
|
||||||
|
? Math.round((new Date(scan.end_time) - new Date(scan.timestamp)) / 1000) + 's'
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono"><a href="/scans/${scan.id}">#${scan.id}</a></td>
|
||||||
|
<td>${new Date(scan.timestamp).toLocaleString()}</td>
|
||||||
|
<td>${getStatusBadge(scan.status)}</td>
|
||||||
|
<td>${duration}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
'running': '<span class="badge bg-primary">Running</span>',
|
||||||
|
'completed': '<span class="badge bg-success">Completed</span>',
|
||||||
|
'failed': '<span class="badge bg-danger">Failed</span>',
|
||||||
|
'pending': '<span class="badge bg-warning">Pending</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || '<span class="badge bg-secondary">' + status + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format relative time
|
||||||
|
function formatRelativeTime(timestamp) {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const diffMs = date - now;
|
||||||
|
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
|
||||||
|
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
|
||||||
|
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
|
||||||
|
|
||||||
|
if (diffMs < 0) {
|
||||||
|
// Past time
|
||||||
|
if (diffMinutes < 1) return 'Just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
} else {
|
||||||
|
// Future time
|
||||||
|
if (diffMinutes < 1) return 'In less than a minute';
|
||||||
|
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
|
||||||
|
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
||||||
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
return `In ${diffDays} days`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cron from template
|
||||||
|
function setCron(expression) {
|
||||||
|
document.getElementById('cron-expression').value = expression;
|
||||||
|
validateCron();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron (basic client-side)
|
||||||
|
function validateCron() {
|
||||||
|
const expression = document.getElementById('cron-expression').value.trim();
|
||||||
|
const feedback = document.getElementById('cron-feedback');
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
feedback.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = expression.split(/\s+/);
|
||||||
|
if (parts.length !== 5) {
|
||||||
|
feedback.className = 'alert alert-danger';
|
||||||
|
feedback.textContent = 'Invalid: Must have exactly 5 fields';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.className = 'alert alert-success';
|
||||||
|
feedback.textContent = 'Valid cron expression';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('edit-schedule-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
name: document.getElementById('schedule-name').value.trim(),
|
||||||
|
cron_expression: document.getElementById('cron-expression').value.trim(),
|
||||||
|
enabled: document.getElementById('schedule-enabled').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Schedule updated successfully! Redirecting...', 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/schedules';
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test run
|
||||||
|
async function testRun() {
|
||||||
|
if (!confirm('Trigger a test run of this schedule now?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/scans/${data.scan_id}`;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error triggering schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
async function deleteSchedule() {
|
||||||
|
const scheduleName = document.getElementById('schedule-name').value;
|
||||||
|
|
||||||
|
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Schedule deleted successfully! Redirecting...', 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/schedules';
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||||
|
notification.style.top = '20px';
|
||||||
|
notification.style.right = '20px';
|
||||||
|
notification.style.zIndex = '9999';
|
||||||
|
notification.style.minWidth = '300px';
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current time display
|
||||||
|
function updateCurrentTime() {
|
||||||
|
const now = new Date();
|
||||||
|
if (document.getElementById('current-local')) {
|
||||||
|
document.getElementById('current-local').textContent = now.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
if (document.getElementById('tz-offset')) {
|
||||||
|
const offset = -now.getTimezoneOffset() / 60;
|
||||||
|
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSchedule();
|
||||||
|
updateCurrentTime();
|
||||||
|
setInterval(updateCurrentTime, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
392
web/templates/schedules.html
Normal file
392
web/templates/schedules.html
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Scheduled Scans - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Scheduled Scans</h1>
|
||||||
|
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="total-schedules">-</div>
|
||||||
|
<div class="stat-label">Total Schedules</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="enabled-schedules">-</div>
|
||||||
|
<div class="stat-label">Enabled</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="next-run-time">-</div>
|
||||||
|
<div class="stat-label">Next Run</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="recent-executions">-</div>
|
||||||
|
<div class="stat-label">Executions (24h)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="schedules-loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Loading schedules...</p>
|
||||||
|
</div>
|
||||||
|
<div id="schedules-error" style="display: none;" class="alert alert-danger">
|
||||||
|
<strong>Error:</strong> <span id="error-message"></span>
|
||||||
|
</div>
|
||||||
|
<div id="schedules-content" style="display: none;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Schedule (Cron)</th>
|
||||||
|
<th>Next Run</th>
|
||||||
|
<th>Last Run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="schedules-tbody">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||||
|
<i class="bi bi-calendar-x" style="font-size: 3rem; color: #64748b;"></i>
|
||||||
|
<h5 class="mt-3 text-muted">No schedules configured</h5>
|
||||||
|
<p class="text-muted">Create your first schedule to automate scans</p>
|
||||||
|
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary mt-2">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Global variables
|
||||||
|
let schedulesData = [];
|
||||||
|
|
||||||
|
// Format relative time (e.g., "in 2 hours", "5 minutes ago")
|
||||||
|
function formatRelativeTime(timestamp) {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const diffMs = date - now;
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
// Get local time string for tooltip/fallback
|
||||||
|
const localStr = date.toLocaleString();
|
||||||
|
|
||||||
|
if (diffMs < 0) {
|
||||||
|
// Past time
|
||||||
|
const absDiffMinutes = Math.abs(diffMinutes);
|
||||||
|
const absDiffHours = Math.abs(diffHours);
|
||||||
|
const absDiffDays = Math.abs(diffDays);
|
||||||
|
|
||||||
|
if (absDiffMinutes < 1) return 'Just now';
|
||||||
|
if (absDiffMinutes === 1) return '1 minute ago';
|
||||||
|
if (absDiffMinutes < 60) return `${absDiffMinutes} minutes ago`;
|
||||||
|
if (absDiffHours === 1) return '1 hour ago';
|
||||||
|
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
|
||||||
|
if (absDiffDays === 1) return 'Yesterday';
|
||||||
|
if (absDiffDays < 7) return `${absDiffDays} days ago`;
|
||||||
|
return `<span title="${localStr}">${absDiffDays} days ago</span>`;
|
||||||
|
} else {
|
||||||
|
// Future time
|
||||||
|
if (diffMinutes < 1) return 'In less than a minute';
|
||||||
|
if (diffMinutes === 1) return 'In 1 minute';
|
||||||
|
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
|
||||||
|
if (diffHours === 1) return 'In 1 hour';
|
||||||
|
if (diffHours < 24) return `In ${diffHours} hours`;
|
||||||
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
|
if (diffDays < 7) return `In ${diffDays} days`;
|
||||||
|
return `<span title="${localStr}">In ${diffDays} days</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge HTML
|
||||||
|
function getStatusBadge(enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
return '<span class="badge bg-success">Enabled</span>';
|
||||||
|
} else {
|
||||||
|
return '<span class="badge bg-secondary">Disabled</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load schedules from API
|
||||||
|
async function loadSchedules() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/schedules');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
schedulesData = data.schedules || [];
|
||||||
|
|
||||||
|
renderSchedules();
|
||||||
|
updateStats(data);
|
||||||
|
|
||||||
|
// Hide loading, show content
|
||||||
|
document.getElementById('schedules-loading').style.display = 'none';
|
||||||
|
document.getElementById('schedules-error').style.display = 'none';
|
||||||
|
document.getElementById('schedules-content').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
document.getElementById('schedules-loading').style.display = 'none';
|
||||||
|
document.getElementById('schedules-content').style.display = 'none';
|
||||||
|
document.getElementById('schedules-error').style.display = 'block';
|
||||||
|
document.getElementById('error-message').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render schedules table
|
||||||
|
function renderSchedules() {
|
||||||
|
const tbody = document.getElementById('schedules-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (schedulesData.length === 0) {
|
||||||
|
document.querySelector('.table-responsive').style.display = 'none';
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('.table-responsive').style.display = 'block';
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
|
||||||
|
schedulesData.forEach(schedule => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.add('schedule-row');
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="mono">#${schedule.id}</td>
|
||||||
|
<td>
|
||||||
|
<strong>${escapeHtml(schedule.name)}</strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
|
||||||
|
</td>
|
||||||
|
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
|
||||||
|
<td>${formatRelativeTime(schedule.next_run)}</td>
|
||||||
|
<td>${formatRelativeTime(schedule.last_run)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="enable-${schedule.id}"
|
||||||
|
${schedule.enabled ? 'checked' : ''}
|
||||||
|
onchange="toggleSchedule(${schedule.id}, this.checked)">
|
||||||
|
<label class="form-check-label" for="enable-${schedule.id}">
|
||||||
|
${schedule.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button class="btn btn-secondary" onclick="triggerSchedule(${schedule.id})"
|
||||||
|
title="Run Now">
|
||||||
|
<i class="bi bi-play-fill"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/schedules/${schedule.id}/edit" class="btn btn-secondary"
|
||||||
|
title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-danger" onclick="deleteSchedule(${schedule.id})"
|
||||||
|
title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
function updateStats(data) {
|
||||||
|
const totalSchedules = data.total || schedulesData.length;
|
||||||
|
const enabledSchedules = schedulesData.filter(s => s.enabled).length;
|
||||||
|
|
||||||
|
// Find next run time
|
||||||
|
let nextRun = null;
|
||||||
|
schedulesData.filter(s => s.enabled && s.next_run).forEach(s => {
|
||||||
|
const scheduleNext = new Date(s.next_run);
|
||||||
|
if (!nextRun || scheduleNext < nextRun) {
|
||||||
|
nextRun = scheduleNext;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate executions in last 24h (would need API support)
|
||||||
|
const recentExecutions = data.recent_executions || 0;
|
||||||
|
|
||||||
|
document.getElementById('total-schedules').textContent = totalSchedules;
|
||||||
|
document.getElementById('enabled-schedules').textContent = enabledSchedules;
|
||||||
|
document.getElementById('next-run-time').innerHTML = nextRun
|
||||||
|
? `<small>${formatRelativeTime(nextRun)}</small>`
|
||||||
|
: '<small>None</small>';
|
||||||
|
document.getElementById('recent-executions').textContent = recentExecutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle schedule enabled/disabled
|
||||||
|
async function toggleSchedule(scheduleId, enabled) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update schedule: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload schedules
|
||||||
|
await loadSchedules();
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
showNotification(`Schedule ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
|
||||||
|
// Revert checkbox
|
||||||
|
document.getElementById(`enable-${scheduleId}`).checked = !enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually trigger schedule
|
||||||
|
async function triggerSchedule(scheduleId) {
|
||||||
|
if (!confirm('Run this schedule now?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to trigger schedule: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
|
||||||
|
|
||||||
|
// Redirect to scan detail page
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/scans/${data.scan_id}`;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error triggering schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
async function deleteSchedule(scheduleId) {
|
||||||
|
const schedule = schedulesData.find(s => s.id === scheduleId);
|
||||||
|
const scheduleName = schedule ? schedule.name : `#${scheduleId}`;
|
||||||
|
|
||||||
|
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/schedules/${scheduleId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete schedule: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Schedule deleted successfully', 'success');
|
||||||
|
|
||||||
|
// Reload schedules
|
||||||
|
await loadSchedules();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting schedule:', error);
|
||||||
|
showNotification(`Error: ${error.message}`, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
// Create notification element
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||||
|
notification.style.top = '20px';
|
||||||
|
notification.style.right = '20px';
|
||||||
|
notification.style.zIndex = '9999';
|
||||||
|
notification.style.minWidth = '300px';
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load schedules on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSchedules();
|
||||||
|
|
||||||
|
// Refresh every 30 seconds
|
||||||
|
setInterval(loadSchedules, 30000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,7 +16,7 @@ def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
|
|||||||
Validate that a configuration file exists and is valid YAML.
|
Validate that a configuration file exists and is valid YAML.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to configuration file
|
file_path: Path to configuration file (absolute or relative filename)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
Tuple of (is_valid, error_message)
|
||||||
@@ -26,6 +26,8 @@ def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
|
|||||||
Examples:
|
Examples:
|
||||||
>>> validate_config_file('/app/configs/example.yaml')
|
>>> validate_config_file('/app/configs/example.yaml')
|
||||||
(True, None)
|
(True, None)
|
||||||
|
>>> validate_config_file('example.yaml')
|
||||||
|
(True, None)
|
||||||
>>> validate_config_file('/nonexistent.yaml')
|
>>> validate_config_file('/nonexistent.yaml')
|
||||||
(False, 'File does not exist: /nonexistent.yaml')
|
(False, 'File does not exist: /nonexistent.yaml')
|
||||||
"""
|
"""
|
||||||
@@ -33,6 +35,10 @@ def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
return False, 'Config file path is required'
|
return False, 'Config file path is required'
|
||||||
|
|
||||||
|
# If file_path is just a filename (not absolute), prepend configs directory
|
||||||
|
if not file_path.startswith('/'):
|
||||||
|
file_path = f'/app/configs/{file_path}'
|
||||||
|
|
||||||
# Convert to Path object
|
# Convert to Path object
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user