initial comment

This commit is contained in:
2026-01-24 17:43:28 -05:00
commit fe40adfd38
72 changed files with 19614 additions and 0 deletions

388
static/css/dashboard.css Normal file
View File

@@ -0,0 +1,388 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f172a;
color: #e2e8f0;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.tab-nav {
background: #1e293b;
border-radius: 12px;
padding: 10px;
margin-bottom: 30px;
display: flex;
gap: 10px;
}
.tab-button {
padding: 12px 24px;
border: none;
background: transparent;
color: #94a3b8;
font-size: 1em;
font-weight: 600;
cursor: pointer;
border-radius: 8px;
transition: all 0.3s;
}
.tab-button:hover {
background: #0f172a;
color: #e2e8f0;
}
.tab-button.active {
background: #667eea;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.controls {
background: #1e293b;
padding: 20px;
border-radius: 12px;
margin-bottom: 30px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #10b981;
color: white;
}
.btn-secondary {
background: #3b82f6;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn:hover {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.9em;
font-weight: 600;
margin-left: auto;
}
.status-active {
background: #10b981;
}
.status-idle {
background: #64748b;
}
select, input[type="text"], input[type="number"] {
padding: 10px 15px;
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 8px;
font-size: 1em;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 10px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-title {
font-size: 0.9em;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 12px;
}
.card-value {
font-size: 2.5em;
font-weight: 700;
}
.card-pending {
border-left: 4px solid #fbbf24;
}
.card-processing {
border-left: 4px solid #3b82f6;
}
.card-completed {
border-left: 4px solid #10b981;
}
.card-failed {
border-left: 4px solid #ef4444;
}
.card-skipped {
border-left: 4px solid #64748b;
}
.progress-section {
background: #1e293b;
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
}
.progress-bar {
width: 100%;
height: 30px;
background: #0f172a;
border-radius: 15px;
overflow: hidden;
margin: 20px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
transition: width 0.5s ease;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.system-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.activity-list, .logs-panel {
background: #1e293b;
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
}
.activity-item {
padding: 12px;
margin-bottom: 8px;
background: #0f172a;
border-radius: 8px;
border-left: 3px solid #10b981;
}
.logs-container {
background: #0f172a;
border-radius: 12px;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 0.85em;
max-height: 400px;
overflow-y: auto;
}
.settings-panel {
background: #1e293b;
border-radius: 12px;
padding: 30px;
}
.settings-section {
margin-bottom: 30px;
}
.settings-section h3 {
font-size: 1.3em;
margin-bottom: 15px;
color: #667eea;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #94a3b8;
}
.form-group input, .form-group select {
width: 100%;
}
.form-group small {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 0.9em;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.button-row {
display: flex;
gap: 15px;
margin-top: 30px;
}
.validation-message {
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
}
.validation-success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid #10b981;
color: #10b981;
}
.validation-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
color: #ef4444;
}
.validation-warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid #f59e0b;
color: #f59e0b;
}
.profile-editor {
background: #0f172a;
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #1e293b;
border-radius: 12px;
padding: 30px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-close {
background: none;
border: none;
color: #94a3b8;
font-size: 1.5em;
cursor: pointer;
}

413
static/js/dashboard.js Normal file
View File

@@ -0,0 +1,413 @@
// Dashboard State
let isProcessing = false;
let currentConfig = {};
let currentProfiles = {};
let editingProfileName = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadConfiguration();
loadProfiles();
refreshData();
setInterval(refreshData, 5000);
});
// Tab Management
function switchTab(tabName) {
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById(tabName + '-tab').classList.add('active');
if (tabName === 'settings') loadConfiguration();
if (tabName === 'profiles') loadProfiles();
}
// Data Refresh
async function refreshData() {
await Promise.all([
updateStats(),
updateSystemStats(),
updateActivity(),
updateLogs(),
checkProcessingStatus()
]);
}
// Configuration Management
async function loadConfiguration() {
try {
const res = await fetch('/api/config');
const result = await res.json();
if (result.success) {
currentConfig = result.data;
document.getElementById('movies_dir').value = result.data.movies_dir || '';
document.getElementById('archive_dir').value = result.data.archive_dir || '';
document.getElementById('work_dir').value = result.data.work_dir || '';
if (result.data.parallel) {
document.getElementById('max_workers').value = result.data.parallel.max_workers || 2;
document.getElementById('gpu_slots').value = result.data.parallel.gpu_slots || 1;
document.getElementById('cpu_slots').value = result.data.parallel.cpu_slots || 4;
}
if (result.data.processing) {
document.getElementById('skip_without_subtitles').checked = result.data.processing.skip_without_subtitles !== false;
document.getElementById('cleanup_stale_work').checked = result.data.processing.cleanup_stale_work !== false;
}
if (result.data.advanced) {
document.getElementById('prefer_gpu').checked = result.data.advanced.prefer_gpu !== false;
document.getElementById('fallback_to_cpu').checked = result.data.advanced.fallback_to_cpu !== false;
}
}
} catch (e) {
console.error('Load config failed:', e);
}
}
async function validateConfiguration() {
const config = gatherConfig();
try {
const res = await fetch('/api/config/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
const result = await res.json();
if (result.success) displayValidation(result.data);
} catch (e) {
alert('Validation failed: ' + e.message);
}
}
async function saveConfiguration() {
const config = gatherConfig();
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
const result = await res.json();
if (result.success) {
alert('✓ Configuration saved!');
currentConfig = result.data;
} else {
alert('Failed: ' + result.error);
}
} catch (e) {
alert('Save failed: ' + e.message);
}
}
function gatherConfig() {
return {
movies_dir: document.getElementById('movies_dir').value,
archive_dir: document.getElementById('archive_dir').value,
work_dir: document.getElementById('work_dir').value,
state_db: currentConfig.state_db || '/var/lib/reencode/state.db',
log_dir: currentConfig.log_dir || '/var/log/reencode',
parallel: {
max_workers: parseInt(document.getElementById('max_workers').value),
gpu_slots: parseInt(document.getElementById('gpu_slots').value),
cpu_slots: parseInt(document.getElementById('cpu_slots').value)
},
processing: {
file_extensions: currentConfig.processing?.file_extensions || ['mkv', 'mp4', 'avi', 'm4v'],
skip_without_subtitles: document.getElementById('skip_without_subtitles').checked,
cleanup_stale_work: document.getElementById('cleanup_stale_work').checked
},
advanced: {
auto_detect_encoders: true,
prefer_gpu: document.getElementById('prefer_gpu').checked,
fallback_to_cpu: document.getElementById('fallback_to_cpu').checked,
progress_interval: 10
},
profiles: currentConfig.profiles || {}
};
}
function displayValidation(data) {
let html = '';
if (data.errors.length === 0 && data.warnings.length === 0) {
html = '<div class="validation-message validation-success">✓ Configuration is valid!</div>';
} else {
if (data.errors.length > 0) {
html += '<div class="validation-message validation-error"><strong>Errors:</strong><ul>';
data.errors.forEach(e => html += `<li>${e}</li>`);
html += '</ul></div>';
}
if (data.warnings.length > 0) {
html += '<div class="validation-message validation-warning"><strong>Warnings:</strong><ul>';
data.warnings.forEach(w => html += `<li>${w}</li>`);
html += '</ul></div>';
}
}
document.getElementById('validationMessages').innerHTML = html;
}
// Profile Management
async function loadProfiles() {
try {
const res = await fetch('/api/profiles');
const result = await res.json();
if (result.success) {
currentProfiles = result.data.profiles;
renderProfiles();
populateProfileSelects(result.data.default);
}
} catch (e) {
console.error('Load profiles failed:', e);
}
}
function renderProfiles() {
let html = '';
for (const [name, profile] of Object.entries(currentProfiles)) {
html += `<div class="profile-editor">
<div class="profile-header">
<strong>${name}</strong>
<div>
<button class="btn btn-secondary" style="padding: 6px 12px;" onclick="editProfile('${name}')">✏️</button>
<button class="btn btn-danger" style="padding: 6px 12px;" onclick="deleteProfile('${name}')">🗑️</button>
</div>
</div>
<div style="color: #94a3b8; font-size: 0.9em;">
Encoder: ${profile.encoder} | Preset: ${profile.preset} | Quality: ${profile.quality}
</div>
</div>`;
}
document.getElementById('profilesList').innerHTML = html;
}
function populateProfileSelects(defaultProfile) {
let opts = '<option value="">Default</option>';
for (const name of Object.keys(currentProfiles)) {
opts += `<option value="${name}">${name}</option>`;
}
document.getElementById('profileSelect').innerHTML = opts;
document.getElementById('default_profile').innerHTML = opts.replace('<option value="">Default</option>', '');
if (defaultProfile) document.getElementById('default_profile').value = defaultProfile;
}
function addNewProfile() {
editingProfileName = null;
document.getElementById('modalTitle').textContent = 'Add Profile';
document.getElementById('modal_profile_name').value = '';
document.getElementById('modal_profile_name').disabled = false;
document.getElementById('modal_encoder').value = 'nvidia_nvenc_h265';
document.getElementById('modal_preset').value = 'medium';
document.getElementById('modal_quality').value = '23';
document.getElementById('modal_audio_codec').value = 'copy';
document.getElementById('profileModal').classList.add('active');
}
function editProfile(name) {
editingProfileName = name;
const profile = currentProfiles[name];
document.getElementById('modalTitle').textContent = 'Edit: ' + name;
document.getElementById('modal_profile_name').value = name;
document.getElementById('modal_profile_name').disabled = true;
document.getElementById('modal_encoder').value = profile.encoder;
document.getElementById('modal_preset').value = profile.preset;
document.getElementById('modal_quality').value = profile.quality;
document.getElementById('modal_audio_codec').value = profile.audio_codec || 'copy';
document.getElementById('profileModal').classList.add('active');
}
function deleteProfile(name) {
if (confirm(`Delete "${name}"?`)) {
delete currentProfiles[name];
renderProfiles();
}
}
function closeProfileModal() {
document.getElementById('profileModal').classList.remove('active');
}
function saveProfileFromModal() {
const name = document.getElementById('modal_profile_name').value.trim();
if (!name) return alert('Name required');
currentProfiles[name] = {
encoder: document.getElementById('modal_encoder').value,
preset: document.getElementById('modal_preset').value,
quality: parseInt(document.getElementById('modal_quality').value),
audio_codec: document.getElementById('modal_audio_codec').value
};
renderProfiles();
populateProfileSelects(currentConfig.profiles?.default);
closeProfileModal();
}
async function saveProfiles() {
const config = gatherConfig();
config.profiles = {
default: document.getElementById('default_profile').value,
definitions: currentProfiles
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
const result = await res.json();
if (result.success) {
alert('✓ Profiles saved!');
currentConfig = result.data;
} else {
alert('Failed: ' + result.error);
}
} catch (e) {
alert('Save failed: ' + e.message);
}
}
// Job Control
async function startProcessing() {
const profile = document.getElementById('profileSelect').value;
const dryRun = document.getElementById('dryRunCheckbox').checked;
try {
const res = await fetch('/api/jobs/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({profile: profile || null, dry_run: dryRun})
});
const result = await res.json();
if (result.success) {
isProcessing = true;
updateUIState();
setTimeout(refreshData, 1000);
} else {
alert('Failed: ' + result.message);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function stopProcessing() {
if (!confirm('Stop processing?')) return;
try {
const res = await fetch('/api/jobs/stop', {method: 'POST'});
const result = await res.json();
if (result.success) {
isProcessing = false;
updateUIState();
setTimeout(refreshData, 1000);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function checkProcessingStatus() {
try {
const res = await fetch('/api/processing');
const result = await res.json();
if (result.success) {
isProcessing = result.data.active;
updateUIState();
}
} catch (e) {}
}
function updateUIState() {
document.getElementById('btnStart').disabled = isProcessing;
document.getElementById('btnStop').disabled = !isProcessing;
const badge = document.getElementById('statusBadge');
if (isProcessing) {
badge.textContent = 'Processing';
badge.className = 'status-badge status-active';
} else {
badge.textContent = 'Idle';
badge.className = 'status-badge status-idle';
}
}
// Stats Updates
async function updateStats() {
try {
const res = await fetch('/api/stats');
const result = await res.json();
if (result.success) {
const d = result.data;
document.getElementById('statPending').textContent = d.pending || 0;
document.getElementById('statProcessing').textContent = d.processing || 0;
document.getElementById('statCompleted').textContent = d.completed || 0;
document.getElementById('statFailed').textContent = d.failed || 0;
document.getElementById('statSkipped').textContent = d.skipped || 0;
const total = (d.completed || 0) + (d.failed || 0) + (d.pending || 0) + (d.processing || 0);
const done = (d.completed || 0) + (d.failed || 0);
const prog = total > 0 ? (done / total * 100) : 0;
document.getElementById('progressBar').style.width = prog + '%';
document.getElementById('originalSize').textContent = formatBytes(d.original_size);
document.getElementById('encodedSize').textContent = formatBytes(d.encoded_size);
document.getElementById('spaceSaved').textContent = formatBytes(d.space_saved) + ' (' + d.space_saved_percent + '%)';
document.getElementById('avgFps').textContent = (d.avg_fps || 0).toFixed(1) + ' fps';
}
} catch (e) {}
}
async function updateSystemStats() {
try {
const res = await fetch('/api/system');
const result = await res.json();
if (result.success) {
let html = '';
if (result.data.gpu && result.data.gpu.length > 0) {
result.data.gpu.forEach(gpu => {
html += `<div class="card" style="background: linear-gradient(135deg, #667eea, #764ba2); color: white;">
<h3>🎮 ${gpu.name}</h3>
<div style="margin-top: 10px;">
GPU: ${gpu.utilization}% | Memory: ${gpu.memory_used}/${gpu.memory_total}MB | Temp: ${gpu.temperature}°C
</div>
</div>`;
});
}
document.getElementById('systemStats').innerHTML = html;
}
} catch (e) {}
}
async function updateActivity() {
try {
const res = await fetch('/api/activity?limit=10');
const result = await res.json();
if (result.success) {
let html = '';
result.data.forEach(item => {
const icon = item.state === 'completed' ? '✓' : '✗';
html += `<div class="activity-item">${icon} ${item.relative_path}</div>`;
});
document.getElementById('activityList').innerHTML = html || '<p>No activity</p>';
}
} catch (e) {}
}
async function updateLogs() {
try {
const res = await fetch('/api/logs?lines=50');
const result = await res.json();
if (result.success) {
const html = result.data.map(line => `<div>${escapeHtml(line)}</div>`).join('');
document.getElementById('logsContainer').innerHTML = html;
}
} catch (e) {}
}
// Utilities
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}