phase3 #2

Merged
ptarrant merged 8 commits from phase3 into master 2025-11-17 18:06:57 +00:00
13 changed files with 2413 additions and 87 deletions
Showing only changes of commit d68d9133c1 - Show all commits

View File

@@ -1,16 +1,16 @@
# Phase 3 Implementation Plan: Dashboard Enhancement & Scheduled Scans
**Status:** Ready to Start
**Progress:** 0/14 days complete (0%)
**Status:** In Progress
**Progress:** 5/14 days complete (36%)
**Estimated Duration:** 14 days (2 weeks)
**Dependencies:** Phase 2 Complete ✅
## Progress Summary
-**Step 1: Fix Styling Issues & CSS Refactor** (Day 1) - COMPLETE
- 📋 **Step 2: ScheduleService Implementation** (Days 2-3) - NEXT
- 📋 **Step 3: Schedules API Endpoints** (Days 4-5)
- 📋 **Step 4: Schedule Management UI** (Days 6-7)
- **Step 2: ScheduleService Implementation** (Days 2-3) - COMPLETE
- **Step 3: Schedules API Endpoints** (Days 4-5) - COMPLETE
- 📋 **Step 4: Schedule Management UI** (Days 6-7) - NEXT
- 📋 **Step 5: Enhanced Dashboard with Charts** (Days 8-9)
- 📋 **Step 6: Scheduler Integration** (Day 10)
- 📋 **Step 7: Scan Comparison Features** (Days 11-12)

639
tests/test_schedule_api.py Normal file
View 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"

View File

@@ -5,9 +5,15 @@ Handles endpoints for managing scheduled scans including CRUD operations
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.services.schedule_service import ScheduleService
from web.services.scan_service import ScanService
logger = logging.getLogger(__name__)
bp = Blueprint('schedules', __name__)
@@ -16,16 +22,36 @@ bp = Blueprint('schedules', __name__)
@api_auth_required
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:
JSON response with schedules list
JSON response with paginated schedules list
"""
# TODO: Implement in Phase 3
return jsonify({
'schedules': [],
'message': 'Schedules list endpoint - to be implemented in Phase 3'
})
try:
# Parse query parameters
page = request.args.get('page', 1, type=int)
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'])
@@ -38,13 +64,20 @@ def get_schedule(schedule_id):
schedule_id: Schedule ID
Returns:
JSON response with schedule details
JSON response with schedule details including execution history
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'message': 'Schedule detail endpoint - to be implemented in Phase 3'
})
try:
schedule_service = ScheduleService(current_app.db_session)
schedule = schedule_service.get_schedule(schedule_id)
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'])
@@ -54,22 +87,60 @@ def create_schedule():
Create a new schedule.
Request body:
name: Schedule name
config_file: Path to YAML config
cron_expression: Cron-like schedule expression
name: Schedule name (required)
config_file: Path to YAML config (required)
cron_expression: Cron expression (required, e.g., '0 2 * * *')
enabled: Whether schedule is active (optional, default: true)
Returns:
JSON response with created schedule ID
"""
# TODO: Implement in Phase 3
data = request.get_json() or {}
try:
data = request.get_json() or {}
return jsonify({
'schedule_id': None,
'status': 'not_implemented',
'message': 'Schedule creation endpoint - to be implemented in Phase 3',
'data': data
}), 501
# 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({
'schedule_id': schedule_id,
'message': 'Schedule created successfully',
'schedule': schedule
}), 201
except ValueError as e:
# Validation error
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error creating schedule: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['PUT'])
@@ -84,21 +155,73 @@ def update_schedule(schedule_id):
Request body:
name: Schedule name (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)
Returns:
JSON response with update status
JSON response with updated schedule
"""
# TODO: Implement in Phase 3
data = request.get_json() or {}
try:
data = request.get_json() or {}
return jsonify({
'schedule_id': schedule_id,
'status': 'not_implemented',
'message': 'Schedule update endpoint - to be implemented in Phase 3',
'data': data
}), 501
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({
'message': 'Schedule updated successfully',
'schedule': updated_schedule
}), 200
except ValueError as e:
# Schedule not found or validation error
if 'not found' in str(e):
return jsonify({'error': str(e)}), 404
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['DELETE'])
@@ -107,18 +230,40 @@ def delete_schedule(schedule_id):
"""
Delete a schedule.
Note: Associated scans are NOT deleted (schedule_id becomes null).
Active scans will complete normally.
Args:
schedule_id: Schedule ID to delete
Returns:
JSON response with deletion status
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'status': 'not_implemented',
'message': 'Schedule deletion endpoint - to be implemented in Phase 3'
}), 501
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({
'message': 'Schedule deleted successfully',
'schedule_id': schedule_id
}), 200
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
@@ -127,19 +272,47 @@ def trigger_schedule(schedule_id):
"""
Manually trigger a scheduled scan.
Creates a new scan with the schedule's configuration and queues it
for immediate execution.
Args:
schedule_id: Schedule ID to trigger
Returns:
JSON response with triggered scan ID
"""
# TODO: Implement in Phase 3
return jsonify({
'schedule_id': schedule_id,
'scan_id': None,
'status': 'not_implemented',
'message': 'Manual schedule trigger endpoint - to be implemented in Phase 3'
}), 501
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({
'message': 'Scan triggered successfully',
'schedule_id': schedule_id,
'scan_id': scan_id
}), 201
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
# Health check endpoint

View File

@@ -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}")
# 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
scanner = SneakyScanner(config_file)
scanner = SneakyScanner(config_path)
# Execute scan
logger.info(f"Scan {scan_id}: Running scanner...")

View File

@@ -66,3 +66,59 @@ def scan_detail(scan_id):
"""
# TODO: Phase 5 - Implement scan detail page
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)

View File

@@ -66,9 +66,15 @@ class ScanService:
if not is_valid:
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
import yaml
with open(config_file, 'r') as f:
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# Create scan record

View File

@@ -64,7 +64,13 @@ class ScheduleService:
raise ValueError(f"Invalid cron expression: {error_msg}")
# 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}")
# Calculate next run time
@@ -196,7 +202,14 @@ class ScheduleService:
# Validate config file if being updated
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']}")
# Handle enabled toggle

View File

@@ -136,35 +136,27 @@ class SchedulerService:
Raises:
RuntimeError: If scheduler not initialized
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:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
# Parse cron expression
# Format: "minute hour day month day_of_week"
parts = cron_expression.split()
if len(parts) != 5:
raise ValueError(f"Invalid cron expression: {cron_expression}")
from apscheduler.triggers.cron import CronTrigger
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(
func=self._trigger_scheduled_scan,
args=[schedule_id, config_file],
trigger='cron',
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
args=[schedule_id],
trigger=trigger,
id=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})")
@@ -191,7 +183,7 @@ class SchedulerService:
except Exception as 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.
@@ -199,17 +191,63 @@ class SchedulerService:
Args:
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}")
# TODO: In Phase 3, this will:
# 1. Create a new Scan record with triggered_by='scheduled'
# 2. Call queue_scan() with the new scan_id
# 3. Update schedule's last_run and next_run timestamps
# Import here to avoid circular imports
from sqlalchemy import create_engine
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]:
"""

View File

@@ -49,6 +49,10 @@
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
href="{{ url_for('main.scans') }}">Scans</a>
</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 class="navbar-nav">
<li class="nav-item">

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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.
Args:
file_path: Path to configuration file
file_path: Path to configuration file (absolute or relative filename)
Returns:
Tuple of (is_valid, error_message)
@@ -26,6 +26,8 @@ def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
Examples:
>>> validate_config_file('/app/configs/example.yaml')
(True, None)
>>> validate_config_file('example.yaml')
(True, None)
>>> validate_config_file('/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:
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
path = Path(file_path)