phase3 #2
@@ -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
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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">
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user