phase3 #2
@@ -1,16 +1,16 @@
|
|||||||
# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans
|
# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans
|
||||||
|
|
||||||
**Status:** Ready to Start
|
**Status:** In Progress
|
||||||
**Progress:** 0/14 days complete (0%)
|
**Progress:** 5/14 days complete (36%)
|
||||||
**Estimated Duration:** 14 days (2 weeks)
|
**Estimated Duration:** 14 days (2 weeks)
|
||||||
**Dependencies:** Phase 2 Complete ✅
|
**Dependencies:** Phase 2 Complete ✅
|
||||||
|
|
||||||
## Progress Summary
|
## Progress Summary
|
||||||
|
|
||||||
- ✅ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE
|
- ✅ **Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE
|
||||||
- 📋 **Step 2: ScheduleService Implementation** (Days 2-3) - NEXT
|
- ✅ **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE
|
||||||
- 📋 **Step 3: Schedules API Endpoints** (Days 4-5)
|
- ✅ **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE
|
||||||
- 📋 **Step 4: Schedule Management UI** (Days 6-7)
|
- 📋 **Step 4: Schedule Management UI** (Days 6-7) - NEXT
|
||||||
- 📋 **Step 5: Enhanced Dashboard with Charts** (Days 8-9)
|
- 📋 **Step 5: Enhanced Dashboard with Charts** (Days 8-9)
|
||||||
- 📋 **Step 6: Scheduler Integration** (Day 10)
|
- 📋 **Step 6: Scheduler Integration** (Day 10)
|
||||||
- 📋 **Step 7: Scan Comparison Features** (Days 11-12)
|
- 📋 **Step 7: Scan Comparison Features** (Days 11-12)
|
||||||
|
|||||||
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"
|
||||||
@@ -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 {}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required = ['name', 'config_file', 'cron_expression']
|
||||||
|
missing = [field for field in required if field not in data]
|
||||||
|
if missing:
|
||||||
|
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
|
||||||
|
|
||||||
|
# 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({
|
return jsonify({
|
||||||
'schedule_id': None,
|
'schedule_id': schedule_id,
|
||||||
'status': 'not_implemented',
|
'message': 'Schedule created successfully',
|
||||||
'message': 'Schedule creation endpoint - to be implemented in Phase 3',
|
'schedule': schedule
|
||||||
'data': data
|
}), 201
|
||||||
}), 501
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No update data provided'}), 400
|
||||||
|
|
||||||
|
# Update schedule
|
||||||
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
|
|
||||||
|
# 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({
|
return jsonify({
|
||||||
'schedule_id': schedule_id,
|
'message': 'Schedule updated successfully',
|
||||||
'status': 'not_implemented',
|
'schedule': updated_schedule
|
||||||
'message': 'Schedule update endpoint - to be implemented in Phase 3',
|
}), 200
|
||||||
'data': data
|
|
||||||
}), 501
|
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:
|
||||||
|
# Remove from APScheduler first
|
||||||
|
if hasattr(current_app, 'scheduler'):
|
||||||
|
try:
|
||||||
|
current_app.scheduler.remove_scheduled_scan(schedule_id)
|
||||||
|
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({
|
return jsonify({
|
||||||
'schedule_id': schedule_id,
|
'message': 'Schedule deleted successfully',
|
||||||
'status': 'not_implemented',
|
'schedule_id': schedule_id
|
||||||
'message': 'Schedule deletion endpoint - to be implemented in Phase 3'
|
}), 200
|
||||||
}), 501
|
|
||||||
|
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:
|
||||||
|
# Get schedule
|
||||||
|
schedule_service = ScheduleService(current_app.db_session)
|
||||||
|
schedule = schedule_service.get_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Trigger scan
|
||||||
|
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({
|
return jsonify({
|
||||||
|
'message': 'Scan triggered successfully',
|
||||||
'schedule_id': schedule_id,
|
'schedule_id': schedule_id,
|
||||||
'scan_id': None,
|
'scan_id': scan_id
|
||||||
'status': 'not_implemented',
|
}), 201
|
||||||
'message': 'Manual schedule trigger endpoint - to be implemented in Phase 3'
|
|
||||||
}), 501
|
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
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -66,3 +66,59 @@ 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('/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
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ class ScheduleService:
|
|||||||
raise ValueError(f"Invalid cron expression: {error_msg}")
|
raise ValueError(f"Invalid cron expression: {error_msg}")
|
||||||
|
|
||||||
# Validate config file exists
|
# Validate config file exists
|
||||||
if not os.path.isfile(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: {config_file}")
|
raise ValueError(f"Config file not found: {config_file}")
|
||||||
|
|
||||||
# Calculate next run time
|
# Calculate next run time
|
||||||
@@ -196,7 +202,14 @@ class ScheduleService:
|
|||||||
|
|
||||||
# Validate config file if being updated
|
# Validate config file if being updated
|
||||||
if 'config_file' in updates:
|
if 'config_file' in updates:
|
||||||
if not os.path.isfile(updates['config_file']):
|
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']}")
|
raise ValueError(f"Config file not found: {updates['config_file']}")
|
||||||
|
|
||||||
# Handle enabled toggle
|
# Handle enabled toggle
|
||||||
|
|||||||
@@ -136,35 +136,27 @@ 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
|
||||||
|
try:
|
||||||
|
trigger = CronTrigger.from_crontab(cron_expression)
|
||||||
|
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 +183,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 +191,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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -49,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">
|
||||||
|
|||||||
427
web/templates/schedule_create.html
Normal file
427
web/templates/schedule_create.html
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
{% 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</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</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> (UTC timezone)
|
||||||
|
</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 (UTC):</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-warning mt-3">
|
||||||
|
<strong>Note:</strong> All times are in UTC timezone. The server is currently at
|
||||||
|
<strong><span id="server-time"></span></strong> UTC.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update server time every second
|
||||||
|
function updateServerTime() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('server-time').textContent = now.toUTCString().split(' ')[4];
|
||||||
|
}
|
||||||
|
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 (00:00 UTC)';
|
||||||
|
}
|
||||||
|
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Runs daily at ${hour.padStart(2, '0')}:00 UTC`;
|
||||||
|
}
|
||||||
|
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} UTC`;
|
||||||
|
}
|
||||||
|
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 %}
|
||||||
569
web/templates/schedule_edit.html
Normal file
569
web/templates/schedule_edit.html
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
{% 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</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</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> (UTC 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">Never</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Next Run:</strong><br>
|
||||||
|
<span id="next-run">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-warning mt-3">
|
||||||
|
<strong>Note:</strong> All times are in UTC timezone.
|
||||||
|
</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
|
||||||
|
document.getElementById('last-run').textContent = schedule.last_run
|
||||||
|
? formatRelativeTime(schedule.last_run) + ' (' + new Date(schedule.last_run).toLocaleString() + ')'
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
|
||||||
|
? formatRelativeTime(schedule.next_run) + ' (' + 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));
|
||||||
|
|
||||||
|
if (diffMs < 0) {
|
||||||
|
if (diffMinutes < 1) return 'Just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} minutes ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hours ago`;
|
||||||
|
return date.toLocaleString();
|
||||||
|
} else {
|
||||||
|
if (diffMinutes < 1) return 'In less than a minute';
|
||||||
|
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
|
||||||
|
if (diffHours < 24) return `In ${diffHours} hours`;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSchedule);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
389
web/templates/schedules.html
Normal file
389
web/templates/schedules.html
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
{% 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);
|
||||||
|
|
||||||
|
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 date.toLocaleString();
|
||||||
|
} 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 date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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