phase 4 complete
This commit is contained in:
14
configs/dmz.yaml
Normal file
14
configs/dmz.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
title: DMZ
|
||||
sites:
|
||||
- name: DMZ Reverse Proxys
|
||||
ips:
|
||||
- address: 10.10.99.10
|
||||
expected:
|
||||
ping: true
|
||||
tcp_ports: [22,80,443]
|
||||
udp_ports: []
|
||||
- address: 10.10.99.20
|
||||
expected:
|
||||
ping: true
|
||||
tcp_ports: [22,80,443]
|
||||
udp_ports: []
|
||||
@@ -11,8 +11,8 @@ services:
|
||||
# Note: Using host network mode for scanner capabilities, so no port mapping needed
|
||||
# The Flask app will be accessible at http://localhost:5000
|
||||
volumes:
|
||||
# Mount configs directory (read-only) for scan configurations
|
||||
- ./configs:/app/configs:ro
|
||||
# Mount configs directory for scan configurations (read-write for web UI management)
|
||||
- ./configs:/app/configs
|
||||
# Mount output directory for scan results
|
||||
- ./output:/app/output
|
||||
# Mount database file for persistence
|
||||
|
||||
1164
docs/ai/Phase4.md
1164
docs/ai/Phase4.md
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# SneakyScanner Roadmap
|
||||
|
||||
**Status:** Phase 2 Complete ✅ | Phase 3 Ready to Start
|
||||
**Status:** Phase 4 Complete ✅ | Phase 5 Next Up
|
||||
|
||||
## Progress Overview
|
||||
- ✅ **Phase 1: Foundation** - Complete (2025-11-13)
|
||||
@@ -16,10 +16,42 @@
|
||||
- Comprehensive error handling and logging
|
||||
- 100 tests passing (1,825 lines of test code)
|
||||
- ✅ **Phase 3: Dashboard & Scheduling** - Complete (2025-11-14)
|
||||
- 📋 **Phase 4: Config Creator ** -Next up
|
||||
- 📋 **Phase 5: Email & Comparisons** - Planned (Weeks 7-8)
|
||||
- 📋 **Phase 6: CLI as API Client** - Planned (Week 9)
|
||||
- 📋 **Phase 7: Advanced Features** - Planned (Weeks 10+)
|
||||
- Dashboard with summary stats and recent scans
|
||||
- Scan history browser with detail pages
|
||||
- Scheduled scan management UI
|
||||
- Background scheduler with APScheduler
|
||||
- Trend charts with Chart.js
|
||||
- ✅ **Phase 4: Config Creator** - Complete (2025-11-17)
|
||||
- CIDR-based config creation (simplified workflow)
|
||||
- YAML editor with CodeMirror (syntax highlighting)
|
||||
- Config management UI (list, view, edit, download, delete)
|
||||
- Direct YAML upload for advanced users
|
||||
- Full REST API for config operations
|
||||
- Schedule dependency protection (delete blocking)
|
||||
- 📋 **Phase 5: Email & Comparisons** - Next up
|
||||
- 📋 **Phase 6: CLI as API Client** - Planned
|
||||
- 📋 **Phase 7: Advanced Features** - Planned
|
||||
|
||||
## Recent Bug Fixes
|
||||
|
||||
### 2025-11-17: Chart.js Infinite Canvas Growth Fix
|
||||
**Issue:** Scan detail page (`scan_detail.html`) was experiencing infinite scrolling and page lock-up due to Chart.js canvas growing infinitely (height reaching 22302px+).
|
||||
|
||||
**Root Causes:**
|
||||
1. Duplicate initialization - `loadScan()` was being called twice on page load
|
||||
2. Multiple Chart.js instances created on the same canvas without destroying previous ones
|
||||
3. Canvas element without fixed-height container caused infinite resize loop with `responsive: true` and `maintainAspectRatio: false`
|
||||
|
||||
**Fixes Applied:**
|
||||
1. **Consolidated initialization** (`scan_detail.html:172-175`) - Moved `findPreviousScan()` and `loadHistoricalChart()` into `DOMContentLoaded` event listener, removed duplicate call
|
||||
2. **Chart instance tracking** (`scan_detail.html:169`) - Added `let historyChart = null;` to store chart reference
|
||||
3. **Destroy old charts** (`scan_detail.html:501-504`) - Added `historyChart.destroy()` before creating new chart instance
|
||||
4. **Fixed-height container** (`scan_detail.html:136-138`) - Wrapped canvas in `<div style="position: relative; height: 300px;">` to prevent infinite resize loop
|
||||
|
||||
**Files Modified:**
|
||||
- `web/templates/scan_detail.html`
|
||||
|
||||
**Status:** ✅ Fixed and tested
|
||||
|
||||
## Vision & Goals
|
||||
|
||||
@@ -843,18 +875,27 @@ All API endpoints return JSON and follow RESTful conventions.
|
||||
- [x] 100 tests passing with comprehensive coverage
|
||||
- [x] Docker deployment production-ready
|
||||
|
||||
### Phase 3 Success (In Progress)
|
||||
- [ ] Dashboard displays scans and trends with charts
|
||||
- [ ] Scheduled scans execute automatically
|
||||
- [ ] Timeline view shows scan history
|
||||
- [ ] Real-time progress updates for running scans
|
||||
### Phase 3 Success ✅ ACHIEVED
|
||||
- [x] Dashboard displays scans and trends with charts
|
||||
- [x] Scheduled scans execute automatically
|
||||
- [x] Historical trend charts show scan history
|
||||
- [x] Real-time progress updates for running scans
|
||||
|
||||
### Phase 4 Success
|
||||
### Phase 4 Success ✅ ACHIEVED
|
||||
- [x] Users can create configs from CIDR ranges via web UI
|
||||
- [x] YAML editor with syntax highlighting works correctly
|
||||
- [x] Config management UI provides list/view/edit/download/delete operations
|
||||
- [x] Direct YAML upload works for advanced users
|
||||
- [x] Configs immediately usable in scan triggers and schedules
|
||||
- [x] Delete protection prevents removal of configs used by schedules
|
||||
- [x] All tests passing (25+ unit and integration tests)
|
||||
|
||||
### Phase 5 Success (Email & Comparisons)
|
||||
- [ ] Email notifications sent for critical alerts
|
||||
- [ ] Comparison reports show meaningful diffs
|
||||
- [ ] Settings UI allows configuration without editing files
|
||||
- [ ] Settings UI allows SMTP configuration without editing files
|
||||
|
||||
### Phase 5 Success
|
||||
### Phase 6 Success (CLI as API Client)
|
||||
- [ ] CLI can trigger scans via API
|
||||
- [ ] API tokens work for authentication
|
||||
- [ ] Standalone CLI mode still functional
|
||||
@@ -904,8 +945,10 @@ All API endpoints return JSON and follow RESTful conventions.
|
||||
| 2025-11-14 | 1.0 | Initial roadmap created based on user requirements |
|
||||
| 2025-11-13 | 1.1 | **Phase 1 COMPLETE** - Database schema, SQLAlchemy models, Flask app structure, settings system with encryption, Alembic migrations, API blueprints, Docker support, validation script |
|
||||
| 2025-11-14 | 1.2 | **Phase 2 COMPLETE** - REST API (5 scan endpoints, 3 settings endpoints), background jobs (APScheduler), authentication (Flask-Login), web UI (dashboard, scans, login, errors), error handling (content negotiation, request IDs, logging), 100 tests passing, comprehensive documentation (API_REFERENCE.md, DEPLOYMENT.md, PHASE2_COMPLETE.md) |
|
||||
| 2025-11-17 | 1.3 | **Bug Fix** - Fixed Chart.js infinite canvas growth issue in scan detail page (duplicate initialization, missing chart.destroy(), missing fixed-height container) |
|
||||
| 2025-11-17 | 1.4 | **Phase 4 COMPLETE** - Config Creator with CIDR-based creation, YAML editor (CodeMirror), config management UI (list/edit/delete), REST API (7 endpoints), Docker volume permissions fix, comprehensive testing and documentation |
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-14
|
||||
**Next Review:** Before Phase 3 kickoff (Dashboard enhancement, trend charts, scheduled scans)
|
||||
**Last Updated:** 2025-11-17
|
||||
**Next Review:** Before Phase 5 kickoff (Email & Comparisons)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Install dependencies
|
||||
pip install -r requirements-web.txt
|
||||
|
||||
# Initialize database
|
||||
python3 init_db.py --password yourpassword
|
||||
|
||||
# Run Flask app
|
||||
python3 -m web.app
|
||||
|
||||
# Test Settings API
|
||||
curl http://localhost:5000/api/settings/health
|
||||
483
tests/test_config_api.py
Normal file
483
tests/test_config_api.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Integration tests for Config API endpoints.
|
||||
|
||||
Tests all config API endpoints including CSV/YAML upload, listing, downloading,
|
||||
and deletion with schedule protection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from web.app import create_app
|
||||
from web.models import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create test application"""
|
||||
# Create temporary database
|
||||
test_db = tempfile.mktemp(suffix='.db')
|
||||
|
||||
# Create temporary configs directory
|
||||
temp_configs_dir = tempfile.mkdtemp()
|
||||
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{test_db}',
|
||||
'SECRET_KEY': 'test-secret-key',
|
||||
'WTF_CSRF_ENABLED': False,
|
||||
})
|
||||
|
||||
# Override configs directory in ConfigService
|
||||
os.environ['CONFIGS_DIR'] = temp_configs_dir
|
||||
|
||||
# Create tables
|
||||
with app.app_context():
|
||||
Base.metadata.create_all(bind=app.db_session.get_bind())
|
||||
|
||||
yield app
|
||||
|
||||
# Cleanup
|
||||
os.unlink(test_db)
|
||||
shutil.rmtree(temp_configs_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
"""Get authentication headers"""
|
||||
# First register and login a user
|
||||
from web.auth.models import User
|
||||
|
||||
with client.application.app_context():
|
||||
# Create test user
|
||||
user = User(username='testuser')
|
||||
user.set_password('testpass')
|
||||
client.application.db_session.add(user)
|
||||
client.application.db_session.commit()
|
||||
|
||||
# Login
|
||||
response = client.post('/auth/login', data={
|
||||
'username': 'testuser',
|
||||
'password': 'testpass'
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Return empty headers (session-based auth)
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csv():
|
||||
"""Sample CSV content"""
|
||||
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
|
||||
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
|
||||
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_yaml():
|
||||
"""Sample YAML content"""
|
||||
return """title: Test Scan
|
||||
sites:
|
||||
- name: Web Servers
|
||||
ips:
|
||||
- address: 10.10.20.4
|
||||
expected:
|
||||
ping: true
|
||||
tcp_ports: [22, 80, 443]
|
||||
udp_ports: [53]
|
||||
services: [ssh, http, https]
|
||||
"""
|
||||
|
||||
|
||||
class TestListConfigs:
|
||||
"""Tests for GET /api/configs"""
|
||||
|
||||
def test_list_configs_empty(self, client, auth_headers):
|
||||
"""Test listing configs when none exist"""
|
||||
response = client.get('/api/configs', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'configs' in data
|
||||
assert data['configs'] == []
|
||||
|
||||
def test_list_configs_with_files(self, client, auth_headers, app, sample_yaml):
|
||||
"""Test listing configs with existing files"""
|
||||
# Create a config file
|
||||
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml)
|
||||
|
||||
response = client.get('/api/configs', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert len(data['configs']) == 1
|
||||
assert data['configs'][0]['filename'] == 'test-scan.yaml'
|
||||
assert data['configs'][0]['title'] == 'Test Scan'
|
||||
assert 'created_at' in data['configs'][0]
|
||||
assert 'size_bytes' in data['configs'][0]
|
||||
assert 'used_by_schedules' in data['configs'][0]
|
||||
|
||||
def test_list_configs_requires_auth(self, client):
|
||||
"""Test that listing configs requires authentication"""
|
||||
response = client.get('/api/configs')
|
||||
assert response.status_code in [401, 302] # Unauthorized or redirect
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
"""Tests for GET /api/configs/<filename>"""
|
||||
|
||||
def test_get_config_valid(self, client, auth_headers, app, sample_yaml):
|
||||
"""Test getting a valid config file"""
|
||||
# Create a config file
|
||||
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml)
|
||||
|
||||
response = client.get('/api/configs/test-scan.yaml', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data['filename'] == 'test-scan.yaml'
|
||||
assert 'content' in data
|
||||
assert 'parsed' in data
|
||||
assert data['parsed']['title'] == 'Test Scan'
|
||||
|
||||
def test_get_config_not_found(self, client, auth_headers):
|
||||
"""Test getting non-existent config"""
|
||||
response = client.get('/api/configs/nonexistent.yaml', headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_get_config_requires_auth(self, client):
|
||||
"""Test that getting config requires authentication"""
|
||||
response = client.get('/api/configs/test.yaml')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestUploadCSV:
|
||||
"""Tests for POST /api/configs/upload-csv"""
|
||||
|
||||
def test_upload_csv_valid(self, client, auth_headers, sample_csv):
|
||||
"""Test uploading valid CSV"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.get_json()
|
||||
assert result['success'] is True
|
||||
assert 'filename' in result
|
||||
assert result['filename'].endswith('.yaml')
|
||||
assert 'preview' in result
|
||||
|
||||
def test_upload_csv_no_file(self, client, auth_headers):
|
||||
"""Test uploading without file"""
|
||||
response = client.post('/api/configs/upload-csv', data={},
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_upload_csv_invalid_format(self, client, auth_headers):
|
||||
"""Test uploading invalid CSV"""
|
||||
from io import BytesIO
|
||||
|
||||
invalid_csv = "not,a,valid,csv\nmissing,columns"
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(invalid_csv.encode('utf-8')), 'test.csv')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
result = response.get_json()
|
||||
assert 'error' in result
|
||||
|
||||
def test_upload_csv_wrong_extension(self, client, auth_headers):
|
||||
"""Test uploading file with wrong extension"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(b'test'), 'test.txt')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_upload_csv_duplicate_filename(self, client, auth_headers, sample_csv):
|
||||
"""Test uploading CSV that generates duplicate filename"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
|
||||
}
|
||||
|
||||
# Upload first time
|
||||
response1 = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response1.status_code == 200
|
||||
|
||||
# Upload second time (should fail)
|
||||
response2 = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response2.status_code == 400
|
||||
|
||||
def test_upload_csv_requires_auth(self, client, sample_csv):
|
||||
"""Test that uploading CSV requires authentication"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-csv', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestUploadYAML:
|
||||
"""Tests for POST /api/configs/upload-yaml"""
|
||||
|
||||
def test_upload_yaml_valid(self, client, auth_headers, sample_yaml):
|
||||
"""Test uploading valid YAML"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.get_json()
|
||||
assert result['success'] is True
|
||||
assert 'filename' in result
|
||||
|
||||
def test_upload_yaml_no_file(self, client, auth_headers):
|
||||
"""Test uploading without file"""
|
||||
response = client.post('/api/configs/upload-yaml', data={},
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_upload_yaml_invalid_syntax(self, client, auth_headers):
|
||||
"""Test uploading YAML with invalid syntax"""
|
||||
from io import BytesIO
|
||||
|
||||
invalid_yaml = "invalid: yaml: syntax: ["
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_upload_yaml_missing_required_fields(self, client, auth_headers):
|
||||
"""Test uploading YAML missing required fields"""
|
||||
from io import BytesIO
|
||||
|
||||
invalid_yaml = """sites:
|
||||
- name: Test
|
||||
ips:
|
||||
- address: 10.0.0.1
|
||||
"""
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_upload_yaml_wrong_extension(self, client, auth_headers):
|
||||
"""Test uploading file with wrong extension"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(b'test'), 'test.txt')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_upload_yaml_requires_auth(self, client, sample_yaml):
|
||||
"""Test that uploading YAML requires authentication"""
|
||||
from io import BytesIO
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
|
||||
}
|
||||
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestDownloadTemplate:
|
||||
"""Tests for GET /api/configs/template"""
|
||||
|
||||
def test_download_template(self, client, auth_headers):
|
||||
"""Test downloading CSV template"""
|
||||
response = client.get('/api/configs/template', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'text/csv; charset=utf-8'
|
||||
assert b'scan_title,site_name,ip_address' in response.data
|
||||
|
||||
def test_download_template_requires_auth(self, client):
|
||||
"""Test that downloading template requires authentication"""
|
||||
response = client.get('/api/configs/template')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestDownloadConfig:
|
||||
"""Tests for GET /api/configs/<filename>/download"""
|
||||
|
||||
def test_download_config_valid(self, client, auth_headers, app, sample_yaml):
|
||||
"""Test downloading existing config"""
|
||||
# Create a config file
|
||||
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml)
|
||||
|
||||
response = client.get('/api/configs/test-scan.yaml/download', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.content_type == 'application/x-yaml; charset=utf-8'
|
||||
assert b'title: Test Scan' in response.data
|
||||
|
||||
def test_download_config_not_found(self, client, auth_headers):
|
||||
"""Test downloading non-existent config"""
|
||||
response = client.get('/api/configs/nonexistent.yaml/download', headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_download_config_requires_auth(self, client):
|
||||
"""Test that downloading config requires authentication"""
|
||||
response = client.get('/api/configs/test.yaml/download')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestDeleteConfig:
|
||||
"""Tests for DELETE /api/configs/<filename>"""
|
||||
|
||||
def test_delete_config_valid(self, client, auth_headers, app, sample_yaml):
|
||||
"""Test deleting a config file"""
|
||||
# Create a config file
|
||||
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml)
|
||||
|
||||
response = client.delete('/api/configs/test-scan.yaml', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify file is deleted
|
||||
assert not os.path.exists(config_path)
|
||||
|
||||
def test_delete_config_not_found(self, client, auth_headers):
|
||||
"""Test deleting non-existent config"""
|
||||
response = client.delete('/api/configs/nonexistent.yaml', headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_config_requires_auth(self, client):
|
||||
"""Test that deleting config requires authentication"""
|
||||
response = client.delete('/api/configs/test.yaml')
|
||||
assert response.status_code in [401, 302]
|
||||
|
||||
|
||||
class TestEndToEndWorkflow:
|
||||
"""End-to-end workflow tests"""
|
||||
|
||||
def test_complete_csv_workflow(self, client, auth_headers, sample_csv):
|
||||
"""Test complete CSV upload workflow"""
|
||||
from io import BytesIO
|
||||
|
||||
# 1. Download template
|
||||
response = client.get('/api/configs/template', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2. Upload CSV
|
||||
data = {
|
||||
'file': (BytesIO(sample_csv.encode('utf-8')), 'workflow-test.csv')
|
||||
}
|
||||
response = client.post('/api/configs/upload-csv', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 200
|
||||
result = response.get_json()
|
||||
filename = result['filename']
|
||||
|
||||
# 3. List configs (should include new one)
|
||||
response = client.get('/api/configs', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
configs = response.get_json()['configs']
|
||||
assert any(c['filename'] == filename for c in configs)
|
||||
|
||||
# 4. Get config details
|
||||
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 5. Download config
|
||||
response = client.get(f'/api/configs/{filename}/download', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 6. Delete config
|
||||
response = client.delete(f'/api/configs/{filename}', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 7. Verify deletion
|
||||
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_yaml_upload_workflow(self, client, auth_headers, sample_yaml):
|
||||
"""Test YAML upload workflow"""
|
||||
from io import BytesIO
|
||||
|
||||
# Upload YAML
|
||||
data = {
|
||||
'file': (BytesIO(sample_yaml.encode('utf-8')), 'yaml-workflow.yaml')
|
||||
}
|
||||
response = client.post('/api/configs/upload-yaml', data=data,
|
||||
headers=auth_headers, content_type='multipart/form-data')
|
||||
assert response.status_code == 200
|
||||
|
||||
filename = response.get_json()['filename']
|
||||
|
||||
# Verify it exists
|
||||
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Clean up
|
||||
client.delete(f'/api/configs/{filename}', headers=auth_headers)
|
||||
505
tests/test_config_service.py
Normal file
505
tests/test_config_service.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
Unit tests for Config Service
|
||||
|
||||
Tests the ConfigService class which manages scan configuration files.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
import tempfile
|
||||
import shutil
|
||||
from web.services.config_service import ConfigService
|
||||
|
||||
|
||||
class TestConfigService:
|
||||
"""Test suite for ConfigService"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_configs_dir(self):
|
||||
"""Create a temporary directory for config files"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, temp_configs_dir):
|
||||
"""Create a ConfigService instance with temp directory"""
|
||||
return ConfigService(configs_dir=temp_configs_dir)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_yaml_config(self):
|
||||
"""Sample YAML config content"""
|
||||
return """title: Test Scan
|
||||
sites:
|
||||
- name: Web Servers
|
||||
ips:
|
||||
- address: 10.10.20.4
|
||||
expected:
|
||||
ping: true
|
||||
tcp_ports: [22, 80, 443]
|
||||
udp_ports: [53]
|
||||
services: [ssh, http, https]
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csv_content(self):
|
||||
"""Sample CSV content"""
|
||||
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
|
||||
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
|
||||
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
|
||||
"""
|
||||
|
||||
def test_list_configs_empty_directory(self, service):
|
||||
"""Test listing configs when directory is empty"""
|
||||
configs = service.list_configs()
|
||||
assert configs == []
|
||||
|
||||
def test_list_configs_with_files(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test listing configs with existing files"""
|
||||
# Create a config file
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
configs = service.list_configs()
|
||||
assert len(configs) == 1
|
||||
assert configs[0]['filename'] == 'test-scan.yaml'
|
||||
assert configs[0]['title'] == 'Test Scan'
|
||||
assert 'created_at' in configs[0]
|
||||
assert 'size_bytes' in configs[0]
|
||||
assert 'used_by_schedules' in configs[0]
|
||||
|
||||
def test_list_configs_ignores_non_yaml_files(self, service, temp_configs_dir):
|
||||
"""Test that non-YAML files are ignored"""
|
||||
# Create non-YAML files
|
||||
with open(os.path.join(temp_configs_dir, 'test.txt'), 'w') as f:
|
||||
f.write('not a yaml file')
|
||||
with open(os.path.join(temp_configs_dir, 'readme.md'), 'w') as f:
|
||||
f.write('# README')
|
||||
|
||||
configs = service.list_configs()
|
||||
assert len(configs) == 0
|
||||
|
||||
def test_get_config_valid(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test getting a valid config file"""
|
||||
# Create a config file
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
result = service.get_config('test-scan.yaml')
|
||||
assert result['filename'] == 'test-scan.yaml'
|
||||
assert 'content' in result
|
||||
assert 'parsed' in result
|
||||
assert result['parsed']['title'] == 'Test Scan'
|
||||
assert len(result['parsed']['sites']) == 1
|
||||
|
||||
def test_get_config_not_found(self, service):
|
||||
"""Test getting a non-existent config"""
|
||||
with pytest.raises(FileNotFoundError, match="not found"):
|
||||
service.get_config('nonexistent.yaml')
|
||||
|
||||
def test_get_config_invalid_yaml(self, service, temp_configs_dir):
|
||||
"""Test getting a config with invalid YAML syntax"""
|
||||
# Create invalid YAML file
|
||||
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write("invalid: yaml: syntax: [")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid YAML syntax"):
|
||||
service.get_config('invalid.yaml')
|
||||
|
||||
def test_create_from_yaml_valid(self, service, sample_yaml_config):
|
||||
"""Test creating config from valid YAML"""
|
||||
filename = service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
||||
assert filename == 'test-scan.yaml'
|
||||
assert service.config_exists('test-scan.yaml')
|
||||
|
||||
# Verify content
|
||||
result = service.get_config('test-scan.yaml')
|
||||
assert result['parsed']['title'] == 'Test Scan'
|
||||
|
||||
def test_create_from_yaml_adds_extension(self, service, sample_yaml_config):
|
||||
"""Test that .yaml extension is added if missing"""
|
||||
filename = service.create_from_yaml('test-scan', sample_yaml_config)
|
||||
assert filename == 'test-scan.yaml'
|
||||
assert service.config_exists('test-scan.yaml')
|
||||
|
||||
def test_create_from_yaml_sanitizes_filename(self, service, sample_yaml_config):
|
||||
"""Test that filename is sanitized"""
|
||||
filename = service.create_from_yaml('../../../etc/test.yaml', sample_yaml_config)
|
||||
# secure_filename should remove path traversal
|
||||
assert '..' not in filename
|
||||
assert '/' not in filename
|
||||
|
||||
def test_create_from_yaml_duplicate_filename(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test creating config with duplicate filename"""
|
||||
# Create first config
|
||||
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
||||
|
||||
# Try to create duplicate
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
||||
|
||||
def test_create_from_yaml_invalid_syntax(self, service):
|
||||
"""Test creating config with invalid YAML syntax"""
|
||||
invalid_yaml = "invalid: yaml: syntax: ["
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid YAML syntax"):
|
||||
service.create_from_yaml('test.yaml', invalid_yaml)
|
||||
|
||||
def test_create_from_yaml_invalid_structure(self, service):
|
||||
"""Test creating config with invalid structure (missing title)"""
|
||||
invalid_config = """sites:
|
||||
- name: Test
|
||||
ips:
|
||||
- address: 10.0.0.1
|
||||
expected:
|
||||
ping: true
|
||||
"""
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required field: 'title'"):
|
||||
service.create_from_yaml('test.yaml', invalid_config)
|
||||
|
||||
def test_create_from_csv_valid(self, service, sample_csv_content):
|
||||
"""Test creating config from valid CSV"""
|
||||
filename, yaml_content = service.create_from_csv(sample_csv_content)
|
||||
assert filename == 'test-scan.yaml'
|
||||
assert service.config_exists(filename)
|
||||
|
||||
# Verify YAML was created correctly
|
||||
result = service.get_config(filename)
|
||||
assert result['parsed']['title'] == 'Test Scan'
|
||||
assert len(result['parsed']['sites']) == 1
|
||||
assert len(result['parsed']['sites'][0]['ips']) == 2
|
||||
|
||||
def test_create_from_csv_with_suggested_filename(self, service, sample_csv_content):
|
||||
"""Test creating config with suggested filename"""
|
||||
filename, yaml_content = service.create_from_csv(sample_csv_content, 'custom-name.yaml')
|
||||
assert filename == 'custom-name.yaml'
|
||||
assert service.config_exists(filename)
|
||||
|
||||
def test_create_from_csv_invalid(self, service):
|
||||
"""Test creating config from invalid CSV"""
|
||||
invalid_csv = """scan_title,site_name,ip_address
|
||||
Missing,Columns,Here
|
||||
"""
|
||||
|
||||
with pytest.raises(ValueError, match="CSV parsing failed"):
|
||||
service.create_from_csv(invalid_csv)
|
||||
|
||||
def test_create_from_csv_duplicate_filename(self, service, sample_csv_content):
|
||||
"""Test creating CSV config with duplicate filename"""
|
||||
# Create first config
|
||||
service.create_from_csv(sample_csv_content)
|
||||
|
||||
# Try to create duplicate (same title generates same filename)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
service.create_from_csv(sample_csv_content)
|
||||
|
||||
def test_delete_config_valid(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test deleting a config file"""
|
||||
# Create a config file
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
assert service.config_exists('test-scan.yaml')
|
||||
service.delete_config('test-scan.yaml')
|
||||
assert not service.config_exists('test-scan.yaml')
|
||||
|
||||
def test_delete_config_not_found(self, service):
|
||||
"""Test deleting non-existent config"""
|
||||
with pytest.raises(FileNotFoundError, match="not found"):
|
||||
service.delete_config('nonexistent.yaml')
|
||||
|
||||
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
|
||||
"""Test deleting config that is used by schedules"""
|
||||
# Create a config file
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
# Mock get_schedules_using_config to return schedules
|
||||
def mock_get_schedules(filename):
|
||||
return ['Daily Scan', 'Weekly Audit']
|
||||
|
||||
monkeypatch.setattr(service, 'get_schedules_using_config', mock_get_schedules)
|
||||
|
||||
with pytest.raises(ValueError, match="used by the following schedules"):
|
||||
service.delete_config('test-scan.yaml')
|
||||
|
||||
# Config should still exist
|
||||
assert service.config_exists('test-scan.yaml')
|
||||
|
||||
def test_validate_config_content_valid(self, service):
|
||||
"""Test validating valid config content"""
|
||||
valid_config = {
|
||||
'title': 'Test Scan',
|
||||
'sites': [
|
||||
{
|
||||
'name': 'Web Servers',
|
||||
'ips': [
|
||||
{
|
||||
'address': '10.10.20.4',
|
||||
'expected': {
|
||||
'ping': True,
|
||||
'tcp_ports': [22, 80, 443],
|
||||
'udp_ports': [53]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(valid_config)
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
def test_validate_config_content_not_dict(self, service):
|
||||
"""Test validating non-dict content"""
|
||||
is_valid, error = service.validate_config_content(['not', 'a', 'dict'])
|
||||
assert is_valid is False
|
||||
assert 'must be a dictionary' in error
|
||||
|
||||
def test_validate_config_content_missing_title(self, service):
|
||||
"""Test validating config without title"""
|
||||
config = {
|
||||
'sites': []
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "Missing required field: 'title'" in error
|
||||
|
||||
def test_validate_config_content_missing_sites(self, service):
|
||||
"""Test validating config without sites"""
|
||||
config = {
|
||||
'title': 'Test'
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "Missing required field: 'sites'" in error
|
||||
|
||||
def test_validate_config_content_empty_title(self, service):
|
||||
"""Test validating config with empty title"""
|
||||
config = {
|
||||
'title': '',
|
||||
'sites': []
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "non-empty string" in error
|
||||
|
||||
def test_validate_config_content_sites_not_list(self, service):
|
||||
"""Test validating config with sites as non-list"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': 'not a list'
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "must be a list" in error
|
||||
|
||||
def test_validate_config_content_no_sites(self, service):
|
||||
"""Test validating config with empty sites list"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': []
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "at least one site" in error
|
||||
|
||||
def test_validate_config_content_site_missing_name(self, service):
|
||||
"""Test validating site without name"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': [
|
||||
{
|
||||
'ips': []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "missing required field: 'name'" in error
|
||||
|
||||
def test_validate_config_content_site_missing_ips(self, service):
|
||||
"""Test validating site without ips"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': [
|
||||
{
|
||||
'name': 'Test Site'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "missing required field: 'ips'" in error
|
||||
|
||||
def test_validate_config_content_site_no_ips(self, service):
|
||||
"""Test validating site with empty ips list"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': [
|
||||
{
|
||||
'name': 'Test Site',
|
||||
'ips': []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "at least one IP" in error
|
||||
|
||||
def test_validate_config_content_ip_missing_address(self, service):
|
||||
"""Test validating IP without address"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': [
|
||||
{
|
||||
'name': 'Test Site',
|
||||
'ips': [
|
||||
{
|
||||
'expected': {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "missing required field: 'address'" in error
|
||||
|
||||
def test_validate_config_content_ip_missing_expected(self, service):
|
||||
"""Test validating IP without expected"""
|
||||
config = {
|
||||
'title': 'Test',
|
||||
'sites': [
|
||||
{
|
||||
'name': 'Test Site',
|
||||
'ips': [
|
||||
{
|
||||
'address': '10.0.0.1'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
is_valid, error = service.validate_config_content(config)
|
||||
assert is_valid is False
|
||||
assert "missing required field: 'expected'" in error
|
||||
|
||||
def test_generate_filename_from_title_simple(self, service):
|
||||
"""Test generating filename from simple title"""
|
||||
filename = service.generate_filename_from_title('Production Scan')
|
||||
assert filename == 'production-scan.yaml'
|
||||
|
||||
def test_generate_filename_from_title_special_chars(self, service):
|
||||
"""Test generating filename with special characters"""
|
||||
filename = service.generate_filename_from_title('Prod Scan (2025)!')
|
||||
assert filename == 'prod-scan-2025.yaml'
|
||||
assert '(' not in filename
|
||||
assert ')' not in filename
|
||||
assert '!' not in filename
|
||||
|
||||
def test_generate_filename_from_title_multiple_spaces(self, service):
|
||||
"""Test generating filename with multiple spaces"""
|
||||
filename = service.generate_filename_from_title('Test Multiple Spaces')
|
||||
assert filename == 'test-multiple-spaces.yaml'
|
||||
# Should not have consecutive hyphens
|
||||
assert '--' not in filename
|
||||
|
||||
def test_generate_filename_from_title_leading_trailing_spaces(self, service):
|
||||
"""Test generating filename with leading/trailing spaces"""
|
||||
filename = service.generate_filename_from_title(' Test Scan ')
|
||||
assert filename == 'test-scan.yaml'
|
||||
assert not filename.startswith('-')
|
||||
assert not filename.endswith('-.yaml')
|
||||
|
||||
def test_generate_filename_from_title_long(self, service):
|
||||
"""Test generating filename from long title"""
|
||||
long_title = 'A' * 300
|
||||
filename = service.generate_filename_from_title(long_title)
|
||||
# Should be limited to 200 chars (195 + .yaml)
|
||||
assert len(filename) <= 200
|
||||
|
||||
def test_generate_filename_from_title_empty(self, service):
|
||||
"""Test generating filename from empty title"""
|
||||
filename = service.generate_filename_from_title('')
|
||||
assert filename == 'config.yaml'
|
||||
|
||||
def test_generate_filename_from_title_only_special_chars(self, service):
|
||||
"""Test generating filename from title with only special characters"""
|
||||
filename = service.generate_filename_from_title('!@#$%^&*()')
|
||||
assert filename == 'config.yaml'
|
||||
|
||||
def test_get_config_path(self, service, temp_configs_dir):
|
||||
"""Test getting config path"""
|
||||
path = service.get_config_path('test.yaml')
|
||||
assert path == os.path.join(temp_configs_dir, 'test.yaml')
|
||||
|
||||
def test_config_exists_true(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test config_exists returns True for existing file"""
|
||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
assert service.config_exists('test-scan.yaml') is True
|
||||
|
||||
def test_config_exists_false(self, service):
|
||||
"""Test config_exists returns False for non-existent file"""
|
||||
assert service.config_exists('nonexistent.yaml') is False
|
||||
|
||||
def test_get_schedules_using_config_none(self, service):
|
||||
"""Test getting schedules when none use the config"""
|
||||
schedules = service.get_schedules_using_config('test.yaml')
|
||||
# Should return empty list (ScheduleService might not exist in test env)
|
||||
assert isinstance(schedules, list)
|
||||
|
||||
def test_list_configs_sorted_by_date(self, service, temp_configs_dir, sample_yaml_config):
|
||||
"""Test that configs are sorted by creation date (most recent first)"""
|
||||
import time
|
||||
|
||||
# Create first config
|
||||
config1_path = os.path.join(temp_configs_dir, 'config1.yaml')
|
||||
with open(config1_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
time.sleep(0.1) # Ensure different timestamps
|
||||
|
||||
# Create second config
|
||||
config2_path = os.path.join(temp_configs_dir, 'config2.yaml')
|
||||
with open(config2_path, 'w') as f:
|
||||
f.write(sample_yaml_config)
|
||||
|
||||
configs = service.list_configs()
|
||||
assert len(configs) == 2
|
||||
# Most recent should be first
|
||||
assert configs[0]['filename'] == 'config2.yaml'
|
||||
assert configs[1]['filename'] == 'config1.yaml'
|
||||
|
||||
def test_list_configs_handles_parse_errors(self, service, temp_configs_dir):
|
||||
"""Test that list_configs handles files that can't be parsed"""
|
||||
# Create invalid YAML file
|
||||
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
|
||||
with open(config_path, 'w') as f:
|
||||
f.write("invalid: yaml: [")
|
||||
|
||||
# Should not raise error, just use filename as title
|
||||
configs = service.list_configs()
|
||||
assert len(configs) == 1
|
||||
assert configs[0]['filename'] == 'invalid.yaml'
|
||||
458
web/api/configs.py
Normal file
458
web/api/configs.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
Configs API blueprint.
|
||||
|
||||
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
|
||||
template download, and config management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import io
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.services.config_service import ConfigService
|
||||
|
||||
bp = Blueprint('configs', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_configs():
|
||||
"""
|
||||
List all config files with metadata.
|
||||
|
||||
Returns:
|
||||
JSON response with list of configs:
|
||||
{
|
||||
"configs": [
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"title": "Prod Scan",
|
||||
"path": "/app/configs/prod-scan.yaml",
|
||||
"created_at": "2025-11-15T10:30:00Z",
|
||||
"size_bytes": 1234,
|
||||
"used_by_schedules": ["Daily Scan"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
configs = config_service.list_configs()
|
||||
|
||||
logger.info(f"Listed {len(configs)} config files")
|
||||
|
||||
return jsonify({
|
||||
'configs': configs
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_config(filename: str):
|
||||
"""
|
||||
Get config file content and parsed data.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
JSON response with config content:
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"content": "title: Prod Scan\n...",
|
||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
logger.info(f"Retrieved config file: {filename}")
|
||||
|
||||
return jsonify(config_data)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config file: {filename} - {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid config',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/create-from-cidr', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_from_cidr():
|
||||
"""
|
||||
Create config from CIDR range.
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"title": "My Scan",
|
||||
"cidr": "10.0.0.0/24",
|
||||
"site_name": "Production" (optional),
|
||||
"ping_default": false (optional)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "my-scan.yaml",
|
||||
"preview": "title: My Scan\n..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Request body must be JSON'
|
||||
}), 400
|
||||
|
||||
# Validate required fields
|
||||
if 'title' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: title'
|
||||
}), 400
|
||||
|
||||
if 'cidr' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: cidr'
|
||||
}), 400
|
||||
|
||||
title = data['title']
|
||||
cidr = data['cidr']
|
||||
site_name = data.get('site_name', None)
|
||||
ping_default = data.get('ping_default', False)
|
||||
|
||||
# Validate title
|
||||
if not title or not title.strip():
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': 'Title cannot be empty'
|
||||
}), 400
|
||||
|
||||
# Create config from CIDR
|
||||
config_service = ConfigService()
|
||||
filename, yaml_preview = config_service.create_from_cidr(
|
||||
title=title,
|
||||
cidr=cidr,
|
||||
site_name=site_name,
|
||||
ping_default=ping_default
|
||||
)
|
||||
|
||||
logger.info(f"Created config from CIDR {cidr}: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'preview': yaml_preview
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"CIDR validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/upload-yaml', methods=['POST'])
|
||||
@api_auth_required
|
||||
def upload_yaml():
|
||||
"""
|
||||
Upload YAML config file directly.
|
||||
|
||||
Request:
|
||||
multipart/form-data with 'file' field containing YAML file
|
||||
Optional 'filename' field for custom filename
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "prod-scan.yaml"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Check if file is present
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file provided'
|
||||
}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
# Check if file is selected
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
|
||||
# Check file extension
|
||||
if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')):
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'File must be a YAML file (.yaml or .yml extension)'
|
||||
}), 400
|
||||
|
||||
# Read YAML content
|
||||
yaml_content = file.read().decode('utf-8')
|
||||
|
||||
# Get filename (use uploaded filename or custom)
|
||||
filename = request.form.get('filename', file.filename)
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Create config from YAML
|
||||
config_service = ConfigService()
|
||||
final_filename = config_service.create_from_yaml(filename, yaml_content)
|
||||
|
||||
logger.info(f"Created config from YAML upload: {final_filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': final_filename
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"YAML validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("YAML file encoding error")
|
||||
return jsonify({
|
||||
'error': 'Encoding error',
|
||||
'message': 'YAML file must be UTF-8 encoded'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.route('/<filename>/download', methods=['GET'])
|
||||
@api_auth_required
|
||||
def download_config(filename: str):
|
||||
"""
|
||||
Download existing config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
YAML file download
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
# Create file-like object
|
||||
yaml_file = io.BytesIO(config_data['content'].encode('utf-8'))
|
||||
yaml_file.seek(0)
|
||||
|
||||
logger.info(f"Config file downloaded: {filename}")
|
||||
|
||||
# Send file
|
||||
return send_file(
|
||||
yaml_file,
|
||||
mimetype='application/x-yaml',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_config(filename: str):
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"content": "title: My Scan\nsites: ..."
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Config updated successfully"
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Invalid YAML or config structure
|
||||
- 404: Config file not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'content' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: content'
|
||||
}), 400
|
||||
|
||||
yaml_content = data['content']
|
||||
|
||||
# Update config
|
||||
config_service = ConfigService()
|
||||
config_service.update_config(filename, yaml_content)
|
||||
|
||||
logger.info(f"Updated config file: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config updated successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config content for {filename}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_config(filename: str):
|
||||
"""
|
||||
Delete config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Config deleted successfully"
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 404: Config file not found
|
||||
- 422: Config is used by schedules (cannot delete)
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_service.delete_config(filename)
|
||||
|
||||
logger.info(f"Deleted config file: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config deleted successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
# Config is used by schedules
|
||||
logger.warning(f"Cannot delete config {filename}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Config in use',
|
||||
'message': str(e)
|
||||
}), 422
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
@@ -61,7 +61,7 @@ def create_app(config: dict = None) -> Flask:
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
JSON_SORT_KEYS=False, # Preserve order in JSON responses
|
||||
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size
|
||||
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
|
||||
)
|
||||
|
||||
# Override with custom config if provided
|
||||
@@ -330,6 +330,7 @@ def register_blueprints(app: Flask) -> None:
|
||||
from web.api.alerts import bp as alerts_bp
|
||||
from web.api.settings import bp as settings_bp
|
||||
from web.api.stats import bp as stats_bp
|
||||
from web.api.configs import bp as configs_bp
|
||||
from web.auth.routes import bp as auth_bp
|
||||
from web.routes.main import bp as main_bp
|
||||
|
||||
@@ -345,6 +346,7 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
||||
|
||||
app.logger.info("Blueprints registered")
|
||||
|
||||
|
||||
@@ -162,3 +162,60 @@ def edit_schedule(schedule_id):
|
||||
# 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)
|
||||
|
||||
|
||||
@bp.route('/configs')
|
||||
@login_required
|
||||
def configs():
|
||||
"""
|
||||
Configuration files list page - shows all config files.
|
||||
|
||||
Returns:
|
||||
Rendered configs list template
|
||||
"""
|
||||
return render_template('configs.html')
|
||||
|
||||
|
||||
@bp.route('/configs/upload')
|
||||
@login_required
|
||||
def upload_config():
|
||||
"""
|
||||
Config upload page - allows CIDR/YAML upload.
|
||||
|
||||
Returns:
|
||||
Rendered config upload template
|
||||
"""
|
||||
return render_template('config_upload.html')
|
||||
|
||||
|
||||
@bp.route('/configs/edit/<filename>')
|
||||
@login_required
|
||||
def edit_config(filename):
|
||||
"""
|
||||
Config edit page - allows editing YAML configuration.
|
||||
|
||||
Args:
|
||||
filename: Config filename to edit
|
||||
|
||||
Returns:
|
||||
Rendered config edit template
|
||||
"""
|
||||
from web.services.config_service import ConfigService
|
||||
from flask import flash, redirect
|
||||
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
return render_template(
|
||||
'config_edit.html',
|
||||
filename=config_data['filename'],
|
||||
content=config_data['content']
|
||||
)
|
||||
except FileNotFoundError:
|
||||
flash(f"Config file '{filename}' not found", 'error')
|
||||
return redirect(url_for('main.configs'))
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config for edit: {e}")
|
||||
flash(f"Error loading config: {str(e)}", 'error')
|
||||
return redirect(url_for('main.configs'))
|
||||
|
||||
495
web/services/config_service.py
Normal file
495
web/services/config_service.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
Config Service - Business logic for config file management
|
||||
|
||||
This service handles all operations related to scan configuration files,
|
||||
including creation, validation, listing, and deletion.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import ipaddress
|
||||
from typing import Dict, List, Tuple, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Business logic for config management"""
|
||||
|
||||
def __init__(self, configs_dir: str = '/app/configs'):
|
||||
"""
|
||||
Initialize the config service.
|
||||
|
||||
Args:
|
||||
configs_dir: Directory where config files are stored
|
||||
"""
|
||||
self.configs_dir = configs_dir
|
||||
|
||||
# Ensure configs directory exists
|
||||
os.makedirs(self.configs_dir, exist_ok=True)
|
||||
|
||||
def list_configs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all config files with metadata.
|
||||
|
||||
Returns:
|
||||
List of config metadata dictionaries:
|
||||
[
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"title": "Prod Scan",
|
||||
"path": "/app/configs/prod-scan.yaml",
|
||||
"created_at": "2025-11-15T10:30:00Z",
|
||||
"size_bytes": 1234,
|
||||
"used_by_schedules": ["Daily Scan", "Weekly Audit"]
|
||||
}
|
||||
]
|
||||
"""
|
||||
configs = []
|
||||
|
||||
# Get all YAML files in configs directory
|
||||
if not os.path.exists(self.configs_dir):
|
||||
return configs
|
||||
|
||||
for filename in os.listdir(self.configs_dir):
|
||||
if not filename.endswith(('.yaml', '.yml')):
|
||||
continue
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get file metadata
|
||||
stat_info = os.stat(filepath)
|
||||
created_at = datetime.fromtimestamp(stat_info.st_mtime).isoformat() + 'Z'
|
||||
size_bytes = stat_info.st_size
|
||||
|
||||
# Parse YAML to get title
|
||||
title = None
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if isinstance(data, dict):
|
||||
title = data.get('title', filename)
|
||||
except Exception:
|
||||
title = filename # Fallback to filename if parsing fails
|
||||
|
||||
# Get schedules using this config
|
||||
used_by_schedules = self.get_schedules_using_config(filename)
|
||||
|
||||
configs.append({
|
||||
'filename': filename,
|
||||
'title': title,
|
||||
'path': filepath,
|
||||
'created_at': created_at,
|
||||
'size_bytes': size_bytes,
|
||||
'used_by_schedules': used_by_schedules
|
||||
})
|
||||
except Exception as e:
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
# Sort by created_at (most recent first)
|
||||
configs.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
return configs
|
||||
|
||||
def get_config(self, filename: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get config file content and parsed data.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"content": "title: Prod Scan\n...",
|
||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
ValueError: If config content is invalid
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Read file content
|
||||
with open(filepath, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
return {
|
||||
'filename': filename,
|
||||
'content': content,
|
||||
'parsed': parsed
|
||||
}
|
||||
|
||||
def create_from_yaml(self, filename: str, content: str) -> str:
|
||||
"""
|
||||
Create config from YAML content.
|
||||
|
||||
Args:
|
||||
filename: Desired filename (will be sanitized)
|
||||
content: YAML content string
|
||||
|
||||
Returns:
|
||||
Final filename (sanitized)
|
||||
|
||||
Raises:
|
||||
ValueError: If content invalid or filename conflict
|
||||
"""
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Ensure .yaml extension
|
||||
if not filename.endswith(('.yaml', '.yml')):
|
||||
filename += '.yaml'
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check for conflicts
|
||||
if os.path.exists(filepath):
|
||||
raise ValueError(f"Config file '{filename}' already exists")
|
||||
|
||||
# Parse and validate YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
# Validate config structure
|
||||
is_valid, error_msg = self.validate_config_content(parsed)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||
|
||||
# Write file
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
return filename
|
||||
|
||||
def create_from_cidr(
|
||||
self,
|
||||
title: str,
|
||||
cidr: str,
|
||||
site_name: Optional[str] = None,
|
||||
ping_default: bool = False
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Create config from CIDR range.
|
||||
|
||||
Args:
|
||||
title: Scan configuration title
|
||||
cidr: CIDR range (e.g., "10.0.0.0/24")
|
||||
site_name: Optional site name (defaults to "Site 1")
|
||||
ping_default: Default ping expectation for all IPs
|
||||
|
||||
Returns:
|
||||
Tuple of (final_filename, yaml_content)
|
||||
|
||||
Raises:
|
||||
ValueError: If CIDR invalid or other validation errors
|
||||
"""
|
||||
# Validate and parse CIDR
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid CIDR range: {str(e)}")
|
||||
|
||||
# Check if network is too large (prevent expansion of huge ranges)
|
||||
if network.num_addresses > 10000:
|
||||
raise ValueError(f"CIDR range too large: {network.num_addresses} addresses. Maximum is 10,000.")
|
||||
|
||||
# Expand CIDR to list of IP addresses
|
||||
ip_list = [str(ip) for ip in network.hosts()]
|
||||
|
||||
# If network has only 1 address (like /32 or /128), hosts() returns empty
|
||||
# In that case, use the network address itself
|
||||
if not ip_list:
|
||||
ip_list = [str(network.network_address)]
|
||||
|
||||
# Build site name
|
||||
if not site_name or not site_name.strip():
|
||||
site_name = "Site 1"
|
||||
|
||||
# Build IP configurations
|
||||
ips = []
|
||||
for ip_address in ip_list:
|
||||
ips.append({
|
||||
'address': ip_address,
|
||||
'expected': {
|
||||
'ping': ping_default,
|
||||
'tcp_ports': [],
|
||||
'udp_ports': []
|
||||
}
|
||||
})
|
||||
|
||||
# Build YAML structure
|
||||
config_data = {
|
||||
'title': title.strip(),
|
||||
'sites': [
|
||||
{
|
||||
'name': site_name.strip(),
|
||||
'ips': ips
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Convert to YAML string
|
||||
yaml_content = yaml.dump(config_data, sort_keys=False, default_flow_style=False)
|
||||
|
||||
# Generate filename from title
|
||||
filename = self.generate_filename_from_title(title)
|
||||
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check for conflicts
|
||||
if os.path.exists(filepath):
|
||||
raise ValueError(f"Config file '{filename}' already exists")
|
||||
|
||||
# Write file
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
return filename, yaml_content
|
||||
|
||||
def update_config(self, filename: str, yaml_content: str) -> None:
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename to update
|
||||
yaml_content: New YAML content string
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
ValueError: If YAML content is invalid
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Parse and validate YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML syntax: {str(e)}")
|
||||
|
||||
# Validate config structure
|
||||
is_valid, error_msg = self.validate_config_content(parsed)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||
|
||||
# Write updated content
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
def delete_config(self, filename: str) -> None:
|
||||
"""
|
||||
Delete config file if not used by schedules.
|
||||
|
||||
Args:
|
||||
filename: Config filename to delete
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config doesn't exist
|
||||
ValueError: If config used by active schedules
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||
|
||||
# Check if used by schedules
|
||||
schedules = self.get_schedules_using_config(filename)
|
||||
if schedules:
|
||||
schedule_list = ', '.join(schedules)
|
||||
raise ValueError(
|
||||
f"Cannot delete config '{filename}' because it is used by the following schedules: {schedule_list}"
|
||||
)
|
||||
|
||||
# Delete file
|
||||
os.remove(filepath)
|
||||
|
||||
def validate_config_content(self, content: Dict) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate parsed YAML config structure.
|
||||
|
||||
Args:
|
||||
content: Parsed YAML config as dict
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not isinstance(content, dict):
|
||||
return False, "Config must be a dictionary/object"
|
||||
|
||||
# Check required fields
|
||||
if 'title' not in content:
|
||||
return False, "Missing required field: 'title'"
|
||||
|
||||
if 'sites' not in content:
|
||||
return False, "Missing required field: 'sites'"
|
||||
|
||||
# Validate title
|
||||
if not isinstance(content['title'], str) or not content['title'].strip():
|
||||
return False, "Field 'title' must be a non-empty string"
|
||||
|
||||
# Validate sites
|
||||
sites = content['sites']
|
||||
if not isinstance(sites, list):
|
||||
return False, "Field 'sites' must be a list"
|
||||
|
||||
if len(sites) == 0:
|
||||
return False, "Must have at least one site defined"
|
||||
|
||||
# Validate each site
|
||||
for i, site in enumerate(sites):
|
||||
if not isinstance(site, dict):
|
||||
return False, f"Site {i+1} must be a dictionary/object"
|
||||
|
||||
if 'name' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'name'"
|
||||
|
||||
if 'ips' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'ips'"
|
||||
|
||||
if not isinstance(site['ips'], list):
|
||||
return False, f"Site {i+1} field 'ips' must be a list"
|
||||
|
||||
if len(site['ips']) == 0:
|
||||
return False, f"Site {i+1} must have at least one IP"
|
||||
|
||||
# Validate each IP
|
||||
for j, ip_config in enumerate(site['ips']):
|
||||
if not isinstance(ip_config, dict):
|
||||
return False, f"Site {i+1} IP {j+1} must be a dictionary/object"
|
||||
|
||||
if 'address' not in ip_config:
|
||||
return False, f"Site {i+1} IP {j+1} missing required field: 'address'"
|
||||
|
||||
if 'expected' not in ip_config:
|
||||
return False, f"Site {i+1} IP {j+1} missing required field: 'expected'"
|
||||
|
||||
if not isinstance(ip_config['expected'], dict):
|
||||
return False, f"Site {i+1} IP {j+1} field 'expected' must be a dictionary/object"
|
||||
|
||||
return True, ""
|
||||
|
||||
def get_schedules_using_config(self, filename: str) -> List[str]:
|
||||
"""
|
||||
Get list of schedule names using this config.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
List of schedule names (e.g., ["Daily Scan", "Weekly Audit"])
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
try:
|
||||
from web.services.schedule_service import ScheduleService
|
||||
schedule_service = ScheduleService()
|
||||
|
||||
# Get all schedules
|
||||
schedules = schedule_service.list_schedules()
|
||||
|
||||
# Build full path for comparison
|
||||
config_path = os.path.join(self.configs_dir, filename)
|
||||
|
||||
# Find schedules using this config
|
||||
using_schedules = []
|
||||
for schedule in schedules:
|
||||
schedule_config = schedule.get('config_file', '')
|
||||
|
||||
# Handle both absolute paths and just filenames
|
||||
if schedule_config == filename or schedule_config == config_path:
|
||||
using_schedules.append(schedule.get('name', 'Unknown'))
|
||||
|
||||
return using_schedules
|
||||
|
||||
except ImportError:
|
||||
# If ScheduleService doesn't exist yet, return empty list
|
||||
return []
|
||||
except Exception:
|
||||
# If any error occurs, return empty list (safer than failing)
|
||||
return []
|
||||
|
||||
def generate_filename_from_title(self, title: str) -> str:
|
||||
"""
|
||||
Generate safe filename from scan title.
|
||||
|
||||
Args:
|
||||
title: Scan title string
|
||||
|
||||
Returns:
|
||||
Safe filename (e.g., "Prod Scan 2025" -> "prod-scan-2025.yaml")
|
||||
"""
|
||||
# Convert to lowercase
|
||||
filename = title.lower()
|
||||
|
||||
# Replace spaces with hyphens
|
||||
filename = filename.replace(' ', '-')
|
||||
|
||||
# Remove special characters (keep only alphanumeric, hyphens, underscores)
|
||||
filename = re.sub(r'[^a-z0-9\-_]', '', filename)
|
||||
|
||||
# Remove consecutive hyphens
|
||||
filename = re.sub(r'-+', '-', filename)
|
||||
|
||||
# Remove leading/trailing hyphens
|
||||
filename = filename.strip('-')
|
||||
|
||||
# Limit length (max 200 chars, reserve 5 for .yaml)
|
||||
max_length = 195
|
||||
if len(filename) > max_length:
|
||||
filename = filename[:max_length]
|
||||
|
||||
# Ensure not empty
|
||||
if not filename:
|
||||
filename = 'config'
|
||||
|
||||
# Add .yaml extension
|
||||
filename += '.yaml'
|
||||
|
||||
return filename
|
||||
|
||||
def get_config_path(self, filename: str) -> str:
|
||||
"""
|
||||
Get absolute path for a config file.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
Absolute path to config file
|
||||
"""
|
||||
return os.path.join(self.configs_dir, filename)
|
||||
|
||||
def config_exists(self, filename: str) -> bool:
|
||||
"""
|
||||
Check if a config file exists.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
return os.path.exists(filepath) and os.path.isfile(filepath)
|
||||
507
web/static/css/config-manager.css
Normal file
507
web/static/css/config-manager.css
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Config Manager Styles
|
||||
* Phase 4: Config Creator - CSS styling for config management UI
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
Dropzone Styling
|
||||
============================================ */
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #6c757d;
|
||||
border-radius: 8px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #1e293b;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: #0d6efd;
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
border-color: #0d6efd;
|
||||
background-color: #1a365d;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.dropzone i {
|
||||
font-size: 48px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropzone p {
|
||||
color: #cbd5e0;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropzone:hover i {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Preview Pane Styling
|
||||
============================================ */
|
||||
|
||||
#yaml-preview {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#yaml-preview pre {
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#yaml-preview pre code {
|
||||
color: #e2e8f0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#preview-placeholder {
|
||||
background-color: #1e293b;
|
||||
border: 2px dashed #475569;
|
||||
border-radius: 8px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
#preview-placeholder i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Config Table Styling
|
||||
============================================ */
|
||||
|
||||
#configs-table {
|
||||
background-color: #1e293b;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#configs-table thead {
|
||||
background-color: #0f172a;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
#configs-table thead th {
|
||||
color: #cbd5e0;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody tr {
|
||||
border-bottom: 1px solid #334155;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#configs-table tbody tr:hover {
|
||||
background-color: #2d3748;
|
||||
}
|
||||
|
||||
#configs-table tbody td {
|
||||
padding: 12px 16px;
|
||||
color: #e2e8f0;
|
||||
vertical-align: middle;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody td code {
|
||||
background-color: #0f172a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #60a5fa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Action Buttons
|
||||
============================================ */
|
||||
|
||||
.config-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
margin-right: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.config-actions .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.config-actions .btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Disabled button styling */
|
||||
.config-actions .btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Schedule Badge
|
||||
============================================ */
|
||||
|
||||
.schedule-badge {
|
||||
display: inline-block;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.schedule-badge:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Search Box
|
||||
============================================ */
|
||||
|
||||
#search {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
#search:focus {
|
||||
background-color: #0f172a;
|
||||
border-color: #3b82f6;
|
||||
color: #e2e8f0;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
#search::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Alert Messages
|
||||
============================================ */
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #7f1d1d;
|
||||
border: 1px solid #991b1b;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #14532d;
|
||||
border: 1px solid #166534;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.alert i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Card Styling
|
||||
============================================ */
|
||||
|
||||
.card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card h5 {
|
||||
color: #cbd5e0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card .text-muted {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tab Navigation
|
||||
============================================ */
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 12px 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #cbd5e0;
|
||||
background-color: #2d3748;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #60a5fa;
|
||||
background-color: transparent;
|
||||
border-color: transparent transparent #60a5fa transparent;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Buttons
|
||||
============================================ */
|
||||
|
||||
.btn {
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #22c55e;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #94a3b8;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #475569;
|
||||
border-color: #475569;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #60a5fa;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: #f87171;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
background-color: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Modal Styling
|
||||
============================================ */
|
||||
|
||||
.modal-content {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Spinner/Loading
|
||||
============================================ */
|
||||
|
||||
.spinner-border {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive Adjustments
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#configs-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#configs-table thead th,
|
||||
#configs-table tbody td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.config-actions .btn {
|
||||
padding: 2px 6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.config-actions .btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
padding: 30px 15px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.dropzone i {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
#yaml-preview pre {
|
||||
max-height: 300px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
/* Stack table cells on very small screens */
|
||||
#configs-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#configs-table tbody tr {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#configs-table tbody td {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#configs-table tbody td:before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utility Classes
|
||||
============================================ */
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.py-5 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Result Count Display
|
||||
============================================ */
|
||||
|
||||
#result-count {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
633
web/static/js/config-manager.js
Normal file
633
web/static/js/config-manager.js
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Config Manager - Handles configuration file upload, management, and display
|
||||
* Phase 4: Config Creator
|
||||
*/
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.apiBase = '/api/configs';
|
||||
this.currentPreview = null;
|
||||
this.currentFilename = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all configurations and populate the table
|
||||
*/
|
||||
async loadConfigs() {
|
||||
try {
|
||||
const response = await fetch(this.apiBase);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.renderConfigsTable(data.configs || []);
|
||||
return data.configs;
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
this.showError('Failed to load configurations: ' + error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration file
|
||||
*/
|
||||
async getConfig(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
this.showError('Failed to load configuration: ' + error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload CSV file and convert to YAML
|
||||
*/
|
||||
async uploadCSV(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/upload-csv`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading CSV:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload YAML file directly
|
||||
*/
|
||||
async uploadYAML(file, filename = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (filename) {
|
||||
formData.append('filename', filename);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/upload-yaml`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading YAML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a configuration file
|
||||
*/
|
||||
async deleteConfig(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download CSV template
|
||||
*/
|
||||
downloadTemplate() {
|
||||
window.location.href = `${this.apiBase}/template`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific config file
|
||||
*/
|
||||
downloadConfig(filename) {
|
||||
window.location.href = `${this.apiBase}/${encodeURIComponent(filename)}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show YAML preview in the preview pane
|
||||
*/
|
||||
showPreview(yamlContent, filename = null) {
|
||||
this.currentPreview = yamlContent;
|
||||
this.currentFilename = filename;
|
||||
|
||||
const previewElement = document.getElementById('yaml-preview');
|
||||
const contentElement = document.getElementById('yaml-content');
|
||||
const placeholderElement = document.getElementById('preview-placeholder');
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.textContent = yamlContent;
|
||||
}
|
||||
|
||||
if (previewElement) {
|
||||
previewElement.style.display = 'block';
|
||||
}
|
||||
|
||||
if (placeholderElement) {
|
||||
placeholderElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Enable save button
|
||||
const saveBtn = document.getElementById('save-config-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide YAML preview
|
||||
*/
|
||||
hidePreview() {
|
||||
this.currentPreview = null;
|
||||
this.currentFilename = null;
|
||||
|
||||
const previewElement = document.getElementById('yaml-preview');
|
||||
const placeholderElement = document.getElementById('preview-placeholder');
|
||||
|
||||
if (previewElement) {
|
||||
previewElement.style.display = 'none';
|
||||
}
|
||||
|
||||
if (placeholderElement) {
|
||||
placeholderElement.style.display = 'block';
|
||||
}
|
||||
|
||||
// Disable save button
|
||||
const saveBtn = document.getElementById('save-config-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render configurations table
|
||||
*/
|
||||
renderConfigsTable(configs) {
|
||||
const tbody = document.querySelector('#configs-table tbody');
|
||||
|
||||
if (!tbody) {
|
||||
console.warn('Configs table body not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing rows
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (configs.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2">No configuration files found. Create your first config!</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate table
|
||||
configs.forEach(config => {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.filename = config.filename;
|
||||
|
||||
// Format date
|
||||
const createdDate = config.created_at ?
|
||||
new Date(config.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) : 'Unknown';
|
||||
|
||||
// Format file size
|
||||
const fileSize = config.size_bytes ?
|
||||
this.formatFileSize(config.size_bytes) : 'Unknown';
|
||||
|
||||
// Schedule usage badge
|
||||
const scheduleCount = config.used_by_schedules ? config.used_by_schedules.length : 0;
|
||||
const scheduleBadge = scheduleCount > 0 ?
|
||||
`<span class="schedule-badge" title="${config.used_by_schedules.join(', ')}">${scheduleCount}</span>` :
|
||||
'<span class="text-muted">None</span>';
|
||||
|
||||
row.innerHTML = `
|
||||
<td><code>${this.escapeHtml(config.filename)}</code></td>
|
||||
<td>${this.escapeHtml(config.title || 'Untitled')}</td>
|
||||
<td>${createdDate}</td>
|
||||
<td>${fileSize}</td>
|
||||
<td>${scheduleBadge}</td>
|
||||
<td class="config-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="configManager.viewConfig('${this.escapeHtml(config.filename)}')"
|
||||
title="View config">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="configManager.downloadConfig('${this.escapeHtml(config.filename)}')"
|
||||
title="Download config">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="configManager.confirmDelete('${this.escapeHtml(config.filename)}', ${scheduleCount})"
|
||||
title="Delete config"
|
||||
${scheduleCount > 0 ? 'disabled' : ''}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Update result count
|
||||
this.updateResultCount(configs.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* View/preview a configuration file
|
||||
*/
|
||||
async viewConfig(filename) {
|
||||
try {
|
||||
const config = await this.getConfig(filename);
|
||||
|
||||
// Show modal with config content
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="viewConfigModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${this.escapeHtml(filename)}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre><code class="language-yaml">${this.escapeHtml(config.content)}</code></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick="configManager.downloadConfig('${this.escapeHtml(filename)}')">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove existing modal if any
|
||||
const existingModal = document.getElementById('viewConfigModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to page
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('viewConfigModal'));
|
||||
modal.show();
|
||||
|
||||
// Clean up on close
|
||||
document.getElementById('viewConfigModal').addEventListener('hidden.bs.modal', function() {
|
||||
this.remove();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.showError('Failed to view configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm deletion of a configuration
|
||||
*/
|
||||
confirmDelete(filename, scheduleCount) {
|
||||
if (scheduleCount > 0) {
|
||||
this.showError(`Cannot delete "${filename}" - it is used by ${scheduleCount} schedule(s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis action cannot be undone.`)) {
|
||||
this.performDelete(filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual deletion
|
||||
*/
|
||||
async performDelete(filename) {
|
||||
try {
|
||||
await this.deleteConfig(filename);
|
||||
this.showSuccess(`Configuration "${filename}" deleted successfully`);
|
||||
|
||||
// Reload configs table
|
||||
await this.loadConfigs();
|
||||
} catch (error) {
|
||||
this.showError('Failed to delete configuration: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configs table by search term
|
||||
*/
|
||||
filterConfigs(searchTerm) {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('#configs-table tbody tr');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
// Skip empty state row
|
||||
if (row.querySelector('td[colspan]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = row.cells[0]?.textContent.toLowerCase() || '';
|
||||
const title = row.cells[1]?.textContent.toLowerCase() || '';
|
||||
|
||||
const matches = filename.includes(term) || title.includes(term);
|
||||
|
||||
row.style.display = matches ? '' : 'none';
|
||||
if (matches) visibleCount++;
|
||||
});
|
||||
|
||||
this.updateResultCount(visibleCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update result count display
|
||||
*/
|
||||
updateResultCount(count) {
|
||||
const countElement = document.getElementById('result-count');
|
||||
if (countElement) {
|
||||
countElement.textContent = `${count} config${count !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message, elementId = 'error-display') {
|
||||
const errorElement = document.getElementById(elementId);
|
||||
if (errorElement) {
|
||||
errorElement.innerHTML = `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i> ${this.escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
errorElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
console.error('Error:', message);
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(message, elementId = 'success-display') {
|
||||
const successElement = document.getElementById(elementId);
|
||||
if (successElement) {
|
||||
successElement.innerHTML = `
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle"></i> ${this.escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
successElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
console.log('Success:', message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages
|
||||
*/
|
||||
clearMessages() {
|
||||
const elements = ['error-display', 'success-display', 'csv-errors', 'yaml-errors'];
|
||||
elements.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize global config manager instance
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
/**
|
||||
* Setup drag-and-drop zone for file uploads
|
||||
*/
|
||||
function setupDropzone(dropzoneId, fileInputId, fileType, onUploadCallback) {
|
||||
const dropzone = document.getElementById(dropzoneId);
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
|
||||
if (!dropzone || !fileInput) {
|
||||
console.warn(`Dropzone setup failed: missing elements (${dropzoneId}, ${fileInputId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Click to browse
|
||||
dropzone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
|
||||
// Drag leave
|
||||
dropzone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
// Drop
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0], fileType, onUploadCallback);
|
||||
}
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0], fileType, onUploadCallback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file upload (CSV or YAML)
|
||||
*/
|
||||
async function handleFileUpload(file, fileType, callback) {
|
||||
configManager.clearMessages();
|
||||
|
||||
// Validate file type
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (fileType === 'csv' && extension !== 'csv') {
|
||||
configManager.showError('Please upload a CSV file (.csv)', 'csv-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileType === 'yaml' && !['yaml', 'yml'].includes(extension)) {
|
||||
configManager.showError('Please upload a YAML file (.yaml or .yml)', 'yaml-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (2MB limit for configs)
|
||||
const maxSize = 2 * 1024 * 1024; // 2MB
|
||||
if (file.size > maxSize) {
|
||||
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
|
||||
configManager.showError(`File too large (${configManager.formatFileSize(file.size)}). Maximum size is 2MB.`, errorId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the provided callback
|
||||
if (callback) {
|
||||
try {
|
||||
await callback(file);
|
||||
} catch (error) {
|
||||
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
|
||||
configManager.showError(error.message, errorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CSV upload and preview
|
||||
*/
|
||||
async function handleCSVUpload(file) {
|
||||
try {
|
||||
// Show loading state
|
||||
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||
}
|
||||
|
||||
// Upload CSV
|
||||
const result = await configManager.uploadCSV(file);
|
||||
|
||||
// Show preview
|
||||
configManager.showPreview(result.preview, result.filename);
|
||||
|
||||
// Show success message
|
||||
configManager.showSuccess(`CSV uploaded successfully! Preview the generated YAML below.`, 'csv-errors');
|
||||
|
||||
} catch (error) {
|
||||
configManager.hidePreview();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle YAML upload
|
||||
*/
|
||||
async function handleYAMLUpload(file) {
|
||||
try {
|
||||
// Upload YAML
|
||||
const result = await configManager.uploadYAML(file);
|
||||
|
||||
// Show success and redirect
|
||||
configManager.showSuccess(`Configuration "${result.filename}" uploaded successfully!`, 'yaml-errors');
|
||||
|
||||
// Redirect to configs list after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/configs';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the previewed configuration (after CSV upload)
|
||||
*/
|
||||
async function savePreviewedConfig() {
|
||||
if (!configManager.currentPreview || !configManager.currentFilename) {
|
||||
configManager.showError('No configuration to save', 'csv-errors');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// The config is already saved during CSV upload, just redirect
|
||||
configManager.showSuccess(`Configuration "${configManager.currentFilename}" saved successfully!`, 'csv-errors');
|
||||
|
||||
// Redirect to configs list after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/configs';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
configManager.showError('Failed to save configuration: ' + error.message, 'csv-errors');
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,10 @@
|
||||
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('main.schedules') }}">Schedules</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
||||
href="{{ url_for('main.configs') }}">Configs</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
|
||||
263
web/templates/config_edit.html
Normal file
263
web/templates/config_edit.html
Normal file
@@ -0,0 +1,263 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Config - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<!-- CodeMirror CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
|
||||
<style>
|
||||
.config-editor-container {
|
||||
background: #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 600px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.validation-feedback {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.validation-feedback.success {
|
||||
background: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.validation-feedback.error {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-lg mt-4">
|
||||
<a href="{{ url_for('main.configs') }}" class="back-link">
|
||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
||||
</a>
|
||||
|
||||
<h2>Edit Configuration</h2>
|
||||
<p class="text-muted">Edit the YAML configuration for <strong>{{ filename }}</strong></p>
|
||||
|
||||
<div class="config-editor-container">
|
||||
<div class="editor-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-code"></i> YAML Editor
|
||||
</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="validateConfig()">
|
||||
<i class="bi bi-check-circle"></i> Validate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea id="yaml-editor">{{ content }}</textarea>
|
||||
|
||||
<div class="validation-feedback" id="validation-feedback"></div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveConfig()">
|
||||
<i class="bi bi-save"></i> Save Changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetEditor()">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Reset
|
||||
</button>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-check-circle-fill"></i> Success
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Configuration updated successfully!
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-success">
|
||||
Back to Configs
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Continue Editing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- CodeMirror JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/yaml/yaml.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize CodeMirror editor
|
||||
const editor = CodeMirror.fromTextArea(document.getElementById('yaml-editor'), {
|
||||
mode: 'yaml',
|
||||
theme: 'dracula',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
indentUnit: 2,
|
||||
tabSize: 2,
|
||||
indentWithTabs: false,
|
||||
extraKeys: {
|
||||
"Tab": function(cm) {
|
||||
cm.replaceSelection(" ", "end");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store original content for reset
|
||||
const originalContent = editor.getValue();
|
||||
|
||||
// Validation function
|
||||
async function validateConfig() {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
const content = editor.getValue();
|
||||
|
||||
try {
|
||||
// Basic YAML syntax check (client-side)
|
||||
// Just check for common YAML issues
|
||||
if (content.trim() === '') {
|
||||
showFeedback('error', 'Configuration cannot be empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for basic structure
|
||||
if (!content.includes('title:')) {
|
||||
showFeedback('error', 'Missing required field: title');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!content.includes('sites:')) {
|
||||
showFeedback('error', 'Missing required field: sites');
|
||||
return false;
|
||||
}
|
||||
|
||||
showFeedback('success', 'Configuration appears valid. Click "Save Changes" to save.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
showFeedback('error', 'Validation error: ' + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
async function saveConfig() {
|
||||
const content = editor.getValue();
|
||||
const filename = '{{ filename }}';
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = event.target;
|
||||
const originalText = saveBtn.innerHTML;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${filename}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Show success modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||
modal.show();
|
||||
} else {
|
||||
// Show error feedback
|
||||
showFeedback('error', data.message || 'Failed to save configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback('error', 'Network error: ' + error.message);
|
||||
} finally {
|
||||
// Restore button state
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset editor to original content
|
||||
function resetEditor() {
|
||||
if (confirm('Are you sure you want to reset all changes?')) {
|
||||
editor.setValue(originalContent);
|
||||
hideFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
// Show validation feedback
|
||||
function showFeedback(type, message) {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
feedback.className = `validation-feedback ${type}`;
|
||||
feedback.innerHTML = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
|
||||
${message}
|
||||
`;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
// Hide validation feedback
|
||||
function hideFeedback() {
|
||||
const feedback = document.getElementById('validation-feedback');
|
||||
feedback.style.display = 'none';
|
||||
}
|
||||
|
||||
// Auto-validate on content change (debounced)
|
||||
let validationTimeout;
|
||||
editor.on('change', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
hideFeedback();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
415
web/templates/config_upload.html
Normal file
415
web/templates/config_upload.html
Normal file
@@ -0,0 +1,415 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Configuration - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
|
||||
<style>
|
||||
.file-info {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 15px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-info-name {
|
||||
color: #60a5fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-info-size {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
{% 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 New Configuration</h1>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Configs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Tabs -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="cidr-tab" data-bs-toggle="tab" data-bs-target="#cidr"
|
||||
type="button" role="tab" style="color: #60a5fa;">
|
||||
<i class="bi bi-diagram-3"></i> Create from CIDR
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="yaml-tab" data-bs-toggle="tab" data-bs-target="#yaml"
|
||||
type="button" role="tab" style="color: #60a5fa;">
|
||||
<i class="bi bi-filetype-yml"></i> Upload YAML
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="uploadTabsContent">
|
||||
<!-- CIDR Form Tab -->
|
||||
<div class="tab-pane fade show active" id="cidr" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-diagram-3"></i> Create Configuration from CIDR Range
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Specify a CIDR range to automatically generate a configuration for all IPs in that range.
|
||||
You can edit the configuration afterwards to add expected ports and services.
|
||||
</p>
|
||||
|
||||
<form id="cidr-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-title" class="form-label" style="color: #94a3b8;">
|
||||
Config Title <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="config-title"
|
||||
placeholder="e.g., Production Infrastructure Scan" required>
|
||||
<div class="form-text">A descriptive title for your scan configuration</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cidr-range" class="form-label" style="color: #94a3b8;">
|
||||
CIDR Range <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="cidr-range"
|
||||
placeholder="e.g., 10.0.0.0/24 or 192.168.1.0/28" required>
|
||||
<div class="form-text">
|
||||
Enter a CIDR range (e.g., 10.0.0.0/24 for 254 hosts).
|
||||
Maximum 10,000 addresses per range.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="site-name" class="form-label" style="color: #94a3b8;">
|
||||
Site Name (optional)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="site-name"
|
||||
placeholder="e.g., Production Servers">
|
||||
<div class="form-text">
|
||||
Logical grouping name for these IPs (default: "Site 1")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="ping-default">
|
||||
<label class="form-check-label" for="ping-default" style="color: #94a3b8;">
|
||||
Expect ping response by default
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Sets the default expectation for ICMP ping responses from these IPs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cidr-errors" class="alert alert-danger" style="display:none;">
|
||||
<strong>Error:</strong> <span id="cidr-error-message"></span>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="cidr-success" class="alert alert-success mt-3" style="display:none;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<strong>Success!</strong> Configuration created: <span id="cidr-created-filename"></span>
|
||||
<div class="mt-2">
|
||||
<a href="#" id="edit-new-config-link" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-pencil"></i> Edit Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YAML Upload Tab -->
|
||||
<div class="tab-pane fade" id="yaml" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 offset-lg-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-cloud-upload"></i> Upload YAML Configuration
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
For advanced users: upload a YAML config file directly.
|
||||
</p>
|
||||
|
||||
<div id="yaml-dropzone" class="dropzone">
|
||||
<i class="bi bi-cloud-upload"></i>
|
||||
<p>Drag & drop YAML file here or click to browse</p>
|
||||
<input type="file" id="yaml-file-input" accept=".yaml,.yml" hidden>
|
||||
</div>
|
||||
|
||||
<div id="yaml-file-info" class="file-info">
|
||||
<div class="file-info-name" id="yaml-filename"></div>
|
||||
<div class="file-info-size" id="yaml-filesize"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="yaml-custom-filename" class="form-label" style="color: #94a3b8;">
|
||||
Custom Filename (optional):
|
||||
</label>
|
||||
<input type="text" id="yaml-custom-filename" class="form-control"
|
||||
placeholder="Leave empty to use uploaded filename">
|
||||
</div>
|
||||
|
||||
<button id="upload-yaml-btn" class="btn btn-primary mt-3" disabled>
|
||||
<i class="bi bi-upload"></i> Upload YAML
|
||||
</button>
|
||||
|
||||
<div id="yaml-errors" class="alert alert-danger mt-3" style="display:none;">
|
||||
<strong>Error:</strong> <span id="yaml-error-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||
<h5 class="modal-title" style="color: #10b981;">
|
||||
<i class="bi bi-check-circle"></i> Success
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: #e2e8f0;">Configuration saved successfully!</p>
|
||||
<p style="color: #60a5fa; font-weight: bold;" id="success-filename"></p>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-primary">
|
||||
<i class="bi bi-list"></i> View All Configs
|
||||
</a>
|
||||
<button type="button" class="btn btn-success" onclick="location.reload()">
|
||||
<i class="bi bi-plus-circle"></i> Create Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Global variables
|
||||
let yamlFile = null;
|
||||
|
||||
// ============== CIDR Form Submission ==============
|
||||
|
||||
document.getElementById('cidr-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = document.getElementById('config-title').value.trim();
|
||||
const cidr = document.getElementById('cidr-range').value.trim();
|
||||
const siteName = document.getElementById('site-name').value.trim();
|
||||
const pingDefault = document.getElementById('ping-default').checked;
|
||||
|
||||
// Validate inputs
|
||||
if (!title) {
|
||||
showError('cidr', 'Config title is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cidr) {
|
||||
showError('cidr', 'CIDR range is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/configs/create-from-cidr', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
cidr: cidr,
|
||||
site_name: siteName || null,
|
||||
ping_default: pingDefault
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide error, show success
|
||||
document.getElementById('cidr-errors').style.display = 'none';
|
||||
document.getElementById('cidr-created-filename').textContent = data.filename;
|
||||
|
||||
// Set edit link
|
||||
document.getElementById('edit-new-config-link').href = `/configs/edit/${data.filename}`;
|
||||
|
||||
document.getElementById('cidr-success').style.display = 'block';
|
||||
|
||||
// Reset form
|
||||
e.target.reset();
|
||||
|
||||
// Show success modal
|
||||
showSuccess(data.filename);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating config from CIDR:', error);
|
||||
showError('cidr', error.message);
|
||||
} finally {
|
||||
// Restore button state
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// ============== YAML Upload ==============
|
||||
|
||||
// Setup YAML dropzone
|
||||
const yamlDropzone = document.getElementById('yaml-dropzone');
|
||||
const yamlFileInput = document.getElementById('yaml-file-input');
|
||||
|
||||
yamlDropzone.addEventListener('click', () => yamlFileInput.click());
|
||||
|
||||
yamlDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
yamlDropzone.classList.add('dragover');
|
||||
});
|
||||
|
||||
yamlDropzone.addEventListener('dragleave', () => {
|
||||
yamlDropzone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
yamlDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
yamlDropzone.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleYAMLFile(file);
|
||||
});
|
||||
|
||||
yamlFileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
handleYAMLFile(file);
|
||||
});
|
||||
|
||||
// Handle YAML file selection
|
||||
function handleYAMLFile(file) {
|
||||
if (!file) return;
|
||||
|
||||
// Check file extension
|
||||
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
|
||||
showError('yaml', 'Please select a YAML file (.yaml or .yml)');
|
||||
return;
|
||||
}
|
||||
|
||||
yamlFile = file;
|
||||
|
||||
// Show file info
|
||||
document.getElementById('yaml-filename').textContent = file.name;
|
||||
document.getElementById('yaml-filesize').textContent = formatFileSize(file.size);
|
||||
document.getElementById('yaml-file-info').style.display = 'block';
|
||||
|
||||
// Enable upload button
|
||||
document.getElementById('upload-yaml-btn').disabled = false;
|
||||
document.getElementById('yaml-errors').style.display = 'none';
|
||||
}
|
||||
|
||||
// Upload YAML file
|
||||
async function uploadYAMLFile() {
|
||||
if (!yamlFile) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', yamlFile);
|
||||
|
||||
const customFilename = document.getElementById('yaml-custom-filename').value.trim();
|
||||
if (customFilename) {
|
||||
formData.append('filename', customFilename);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/configs/upload-yaml', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showSuccess(data.filename);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading YAML:', error);
|
||||
showError('yaml', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('upload-yaml-btn').addEventListener('click', uploadYAMLFile);
|
||||
|
||||
// ============== Helper Functions ==============
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Show error
|
||||
function showError(type, message) {
|
||||
const errorDiv = document.getElementById(`${type}-errors`);
|
||||
const errorMsg = document.getElementById(`${type}-error-message`);
|
||||
errorMsg.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show success
|
||||
function showSuccess(filename) {
|
||||
document.getElementById('success-filename').textContent = filename;
|
||||
new bootstrap.Modal(document.getElementById('successModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
377
web/templates/configs.html
Normal file
377
web/templates/configs.html
Normal file
@@ -0,0 +1,377 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Configuration Files - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
|
||||
{% 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;">Configuration Files</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create New Config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-configs">-</div>
|
||||
<div class="stat-label">Total Configs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="configs-in-use">-</div>
|
||||
<div class="stat-label">In Use by Schedules</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-size">-</div>
|
||||
<div class="stat-label">Total Size</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configs Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5>
|
||||
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
||||
placeholder="Search configs...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="configs-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 configurations...</p>
|
||||
</div>
|
||||
<div id="configs-error" style="display: none;" class="alert alert-danger">
|
||||
<strong>Error:</strong> <span id="error-message"></span>
|
||||
</div>
|
||||
<div id="configs-content" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Used By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="configs-tbody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
|
||||
<h5 class="mt-3 text-muted">No configuration files</h5>
|
||||
<p class="text-muted">Create your first config to define scan targets</p>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
|
||||
<i class="bi bi-plus-circle"></i> Create Config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||
<h5 class="modal-title" style="color: #f87171;">
|
||||
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
|
||||
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
|
||||
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
This config is used by schedules and cannot be deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Config Modal -->
|
||||
<div class="modal fade" id="viewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||
<h5 class="modal-title" style="color: #60a5fa;">
|
||||
<i class="bi bi-file-earmark-code"></i> Config File Details
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
|
||||
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
|
||||
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<a id="download-link" href="#" class="btn btn-primary">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Global variables
|
||||
let configsData = [];
|
||||
let selectedConfigForDeletion = null;
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'Unknown';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// Load configs from API
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
document.getElementById('configs-loading').style.display = 'block';
|
||||
document.getElementById('configs-error').style.display = 'none';
|
||||
document.getElementById('configs-content').style.display = 'none';
|
||||
|
||||
const response = await fetch('/api/configs');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
configsData = data.configs || [];
|
||||
|
||||
updateStats();
|
||||
renderConfigs(configsData);
|
||||
|
||||
document.getElementById('configs-loading').style.display = 'none';
|
||||
document.getElementById('configs-content').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
document.getElementById('configs-loading').style.display = 'none';
|
||||
document.getElementById('configs-error').style.display = 'block';
|
||||
document.getElementById('error-message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary stats
|
||||
function updateStats() {
|
||||
const totalConfigs = configsData.length;
|
||||
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
|
||||
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
|
||||
|
||||
document.getElementById('total-configs').textContent = totalConfigs;
|
||||
document.getElementById('configs-in-use').textContent = configsInUse;
|
||||
document.getElementById('total-size').textContent = formatFileSize(totalSize);
|
||||
}
|
||||
|
||||
// Render configs table
|
||||
function renderConfigs(configs) {
|
||||
const tbody = document.getElementById('configs-tbody');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (configs.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
tbody.innerHTML = configs.map(config => {
|
||||
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
|
||||
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
|
||||
: '<span class="badge bg-secondary">Not used</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><code>${config.filename}</code></td>
|
||||
<td>${config.title || config.filename}</td>
|
||||
<td>${formatDate(config.created_at)}</td>
|
||||
<td>${formatFileSize(config.size_bytes || 0)}</td>
|
||||
<td>${usedByBadge}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// View config details
|
||||
async function viewConfig(filename) {
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${filename}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load config: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('view-filename').textContent = data.filename;
|
||||
document.getElementById('view-content').textContent = data.content;
|
||||
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('viewModal')).show();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error viewing config:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete
|
||||
function confirmDelete(filename, isInUse) {
|
||||
selectedConfigForDeletion = filename;
|
||||
document.getElementById('delete-config-name').textContent = filename;
|
||||
|
||||
const warningDiv = document.getElementById('delete-warning-schedules');
|
||||
const deleteBtn = document.getElementById('confirm-delete-btn');
|
||||
|
||||
if (isInUse) {
|
||||
warningDiv.style.display = 'block';
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('disabled');
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.classList.remove('disabled');
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
|
||||
// Delete config
|
||||
async function deleteConfig() {
|
||||
if (!selectedConfigForDeletion) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
|
||||
|
||||
// Reload configs
|
||||
await loadConfigs();
|
||||
|
||||
// Show success message
|
||||
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting config:', error);
|
||||
showAlert('danger', `Error deleting config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show alert
|
||||
function showAlert(type, message) {
|
||||
const alertHtml = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
const alert = container.querySelector('.alert');
|
||||
if (alert) {
|
||||
bootstrap.Alert.getInstance(alert)?.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
|
||||
if (!searchTerm) {
|
||||
renderConfigs(configsData);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = configsData.filter(config =>
|
||||
config.filename.toLowerCase().includes(searchTerm) ||
|
||||
(config.title && config.title.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
|
||||
renderConfigs(filtered);
|
||||
});
|
||||
|
||||
// Setup delete button
|
||||
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
|
||||
|
||||
// Load configs on page load
|
||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -360,6 +360,9 @@
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide error before closing modal to prevent flash
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
|
||||
|
||||
@@ -370,7 +373,9 @@
|
||||
Scan triggered successfully! (ID: ${data.scan_id})
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// Refresh scans and stats
|
||||
refreshScans();
|
||||
|
||||
@@ -2,57 +2,6 @@
|
||||
|
||||
{% block title %}Login - SneakyScanner{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.container-fluid {
|
||||
max-width: 450px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% set hide_nav = true %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -133,7 +133,9 @@
|
||||
<p class="text-muted mb-3">
|
||||
Historical port count trend for scans using the same configuration
|
||||
</p>
|
||||
<canvas id="historyChart" height="80"></canvas>
|
||||
<div style="position: relative; height: 300px;">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,10 +168,14 @@
|
||||
<script>
|
||||
const scanId = {{ scan_id }};
|
||||
let scanData = null;
|
||||
let historyChart = null; // Store chart instance to prevent duplicates
|
||||
|
||||
// Load scan on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadScan();
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
|
||||
// Auto-refresh every 10 seconds if scan is running
|
||||
setInterval(function() {
|
||||
@@ -494,8 +500,13 @@
|
||||
if (data.scans && data.scans.length > 1) {
|
||||
document.getElementById('historical-chart-row').style.display = 'block';
|
||||
|
||||
// Destroy existing chart to prevent canvas growth bug
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
historyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -570,11 +581,5 @@
|
||||
console.error('Error loading historical chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: find previous scan and load chart after loading current scan
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -396,6 +396,9 @@
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Hide error before closing modal to prevent flash
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
// Close modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
|
||||
|
||||
@@ -406,7 +409,9 @@
|
||||
Scan triggered successfully! (ID: ${data.scan_id})
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
|
||||
// Insert at the beginning of container-fluid
|
||||
const container = document.querySelector('.container-fluid');
|
||||
container.insertBefore(alertDiv, container.firstChild);
|
||||
|
||||
// Refresh scans
|
||||
loadScans();
|
||||
|
||||
Reference in New Issue
Block a user