restructure of dirs, huge docs update
This commit is contained in:
507
app/web/static/css/config-manager.css
Normal file
507
app/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;
|
||||
}
|
||||
334
app/web/static/css/styles.css
Normal file
334
app/web/static/css/styles.css
Normal file
@@ -0,0 +1,334 @@
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
/* Custom Variables */
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--bg-quaternary: #475569;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #334155;
|
||||
--accent-blue: #60a5fa;
|
||||
--success-bg: #065f46;
|
||||
--success-text: #6ee7b7;
|
||||
--success-border: #10b981;
|
||||
--warning-bg: #78350f;
|
||||
--warning-text: #fcd34d;
|
||||
--warning-border: #f59e0b;
|
||||
--danger-bg: #7f1d1d;
|
||||
--danger-text: #fca5a5;
|
||||
--danger-border: #ef4444;
|
||||
--info-bg: #1e3a8a;
|
||||
--info-text: #93c5fd;
|
||||
--info-border: #3b82f6;
|
||||
|
||||
/* Bootstrap 5 Variable Overrides for Dark Theme */
|
||||
--bs-body-bg: #0f172a;
|
||||
--bs-body-color: #e2e8f0;
|
||||
--bs-border-color: #334155;
|
||||
--bs-border-color-translucent: rgba(51, 65, 85, 0.5);
|
||||
|
||||
/* Table Variables */
|
||||
--bs-table-bg: #1e293b;
|
||||
--bs-table-color: #e2e8f0;
|
||||
--bs-table-border-color: #334155;
|
||||
--bs-table-striped-bg: #1e293b;
|
||||
--bs-table-striped-color: #e2e8f0;
|
||||
--bs-table-active-bg: #334155;
|
||||
--bs-table-active-color: #e2e8f0;
|
||||
--bs-table-hover-bg: #334155;
|
||||
--bs-table-hover-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Global Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-custom {
|
||||
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
|
||||
border-bottom: 1px solid var(--bg-quaternary);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary) !important;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container-fluid {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--bg-quaternary);
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--accent-blue);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-expected,
|
||||
.badge-good,
|
||||
.badge-success {
|
||||
background-color: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.badge-unexpected,
|
||||
.badge-critical,
|
||||
.badge-danger {
|
||||
background-color: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.badge-missing,
|
||||
.badge-warning {
|
||||
background-color: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-quaternary);
|
||||
border-color: var(--bg-quaternary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-bg);
|
||||
border-color: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #991b1b;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
/* Tables - Fix for dynamically created table rows (white row bug) */
|
||||
.table {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table tbody tr,
|
||||
.table tbody tr.scan-row {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: var(--danger-bg);
|
||||
border-color: var(--danger-border);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stat-card {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-muted {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success-border) !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning-border) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger-border) !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Spinner for loading states */
|
||||
.spinner-border-sm {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Chart.js Dark Theme Styles */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
633
app/web/static/js/config-manager.js
Normal file
633
app/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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user