не работает но компилируется

This commit is contained in:
2026-04-20 15:13:28 +03:00
parent 855bdcc3f9
commit 02fd7a65a7
28 changed files with 3786 additions and 4692 deletions

View File

@@ -11,18 +11,31 @@ pub struct Config {
// Nexrender
pub nexrender_api_url: String,
pub output_folder: String,
// Templates (all required)
// Templates
pub template_double_src: String,
pub template_single_src: String,
pub template_composition: String,
pub template_output_module: String,
pub template_output_ext: String,
//web_serwer
// Web
pub web_port: u16,
// SMB for approved videos (optional)
pub smb_upload_url: Option<String>,
pub smb_username: Option<String>,
pub smb_password: Option<String>,
pub smb_workgroup: Option<String>,
// SMB source folder (where videos are stored locally before upload)
pub smb_source_folder: String,
// Database
pub database_url: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
let output_folder = env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?;
let smb_source_folder =
env::var("SMB_SOURCE_FOLDER").unwrap_or_else(|_| output_folder.clone());
Ok(Self {
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
@@ -30,7 +43,7 @@ impl Config {
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
nexrender_api_url: env::var("NEXRENDER_API_URL")
.context("NEXRENDER_API_URL not set")?,
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?,
output_folder,
template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
.context("TEMPLATE_DOUBLE_SRC not set")?,
template_single_src: env::var("TEMPLATE_SINGLE_SRC")
@@ -45,6 +58,13 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("Invalid WEB_PORT")?,
smb_upload_url: env::var("SMB_UPLOAD_URL").ok(),
smb_username: env::var("SMB_USERNAME").ok(),
smb_password: env::var("SMB_PASSWORD").ok(),
smb_workgroup: env::var("SMB_WORKGROUP").ok(),
smb_source_folder,
database_url: env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:ae_anons.db".to_string()),
})
}
}

View File

@@ -1,6 +1,7 @@
mod config;
mod nexrender;
mod processor;
mod storage;
mod synology;
mod web;
@@ -13,40 +14,31 @@ use log::info;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Run web server instead of one-time processing
#[arg(short, long)]
web: bool,
/// Run one-time processing (default if no flags)
#[arg(short, long)]
once: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
// Загружаем .env до инициализации логгера
dotenv().ok();
// Инициализируем логгер
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cli = Cli::parse();
let config = Config::from_env()?;
if cli.web {
info!(
"Starting AE Anons web server v{}",
env!("CARGO_PKG_VERSION")
);
info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION"));
web::run_web_server(config).await?;
} else {
info!(
"Starting AE Anons processor v{} (one-time mode)",
env!("CARGO_PKG_VERSION")
);
let submitted = processor::process_spreadsheet(&config).await?;
info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION"));
// For one-time mode we don't need SMB, so we create dummy db and channel
let (dummy_ws_tx, _) = tokio::sync::broadcast::channel(1);
let dummy_db = sqlx::SqlitePool::connect("sqlite::memory:").await?;
let submitted = processor::process_spreadsheet(&config, &dummy_db, dummy_ws_tx).await?;
info!("Submitted {} jobs. Exiting.", submitted.len());
}
Ok(())
}
}

View File

@@ -1,15 +1,18 @@
use crate::config::Config;
use crate::nexrender::{JobData, LogoRegistry};
use crate::synology::SynologyClient;
use crate::storage::{self, JobRecord};
use crate::web::WsEvent;
use anyhow::{anyhow, Result};
use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate};
use log::{debug, error, info, log_enabled, Level};
use chrono::{Duration, NaiveDate, Utc};
use log::{debug, error, info};
use reqwest::Client;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::Path;
use tokio::sync::broadcast;
#[derive(Debug, Clone)]
pub struct SheetData {
@@ -29,8 +32,7 @@ impl ExcelWorkbook {
}
}
/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender
pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)>> {
pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx: broadcast::Sender<WsEvent>) -> Result<Vec<(String, String)>> {
let mut client = SynologyClient::new(&config.nas_fqdn);
client.login(&config.nas_user, &config.nas_pass).await?;
info!("Successfully authenticated with Synology NAS");
@@ -38,11 +40,10 @@ pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)
let info = client.get_info().await?;
info!("Connected to NAS: {}", info.hostname);
// Скачиваем и парсим Excel в памяти
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
display_workbook_structure(&workbook);
let submitted = generate_and_submit_jobs(&workbook, config).await?;
let submitted = generate_and_submit_jobs(&workbook, config, db, ws_tx).await?;
client.logout().await?;
info!("Session terminated successfully");
@@ -105,7 +106,6 @@ async fn download_and_parse_excel_in_memory(
info!("Found file: {}", actual_file_name);
info!("Exporting file from Synology Office to Excel format (in-memory)...");
// Получаем бинарные данные Excel напрямую в память
let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
info!("Exported {} bytes to memory", excel_data.len());
@@ -158,7 +158,6 @@ fn parse_sheet_dynamic_optimized(
});
}
// Извлекаем заголовки с дедупликацией
let headers: Vec<String> = data_matrix[0]
.iter()
.enumerate()
@@ -192,7 +191,7 @@ fn parse_sheet_dynamic_optimized(
None
}
})
.take(10000) // Ограничение для безопасности
.take(10000)
.collect();
Ok(SheetData {
@@ -274,7 +273,6 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
}
}
// Вспомогательная функция для поиска значения по части ключа
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
if let Some(val) = row.get(key_part) {
if !val.is_empty() {
@@ -292,73 +290,41 @@ fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<Strin
async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook,
config: &Config,
db: &sqlx::SqlitePool,
ws_tx: broadcast::Sender<WsEvent>,
) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs...");
// ========== ЗАГРУЖАЕМ SPORT PACKS ==========
// Load SPORT packs
let sport_sheet = workbook
.get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
let packs: HashMap<String, String> = sport_sheet
.rows
.iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect();
info!("Loaded {} sport packs", packs.len());
debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len());
// ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
// Load TEAM logos
let teams_sheet = workbook
.get_sheet("TEAMS")
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
debug!("TEAMS headers: {:?}", teams_sheet.headers);
if log_enabled!(Level::Debug) {
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
debug!("TEAMS row {}: {:?}", i, row);
}
}
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
for row in &teams_sheet.rows {
let team = get_cell_fuzzy(row, "TEAM");
let sport = get_cell_fuzzy(row, "SPORT");
let link = get_cell_fuzzy(row, "LINK");
if let (Some(team), Some(sport), Some(link)) = (team, sport, link) {
logos.insert(team.clone(), sport.clone(), link.clone());
}
}
let total_teams = teams_sheet.rows.len();
info!("Loaded {} team logos", total_teams);
info!("Loaded {} team logos", teams_sheet.rows.len());
// Дебаг: выводим статистику по TEAMS
if log_enabled!(Level::Debug) {
let sports: HashSet<_> = teams_sheet
.rows
.iter()
.filter_map(|r| r.get("SPORT"))
.collect();
debug!("Sports found in TEAMS sheet: {:?}", sports);
for sport in sports.iter().take(5) {
let examples: Vec<_> = teams_sheet
.rows
.iter()
.filter(|r| r.get("SPORT") == Some(*sport))
.take(3)
.filter_map(|r| r.get("TEAM"))
.collect();
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
}
}
// ========== ЗАГРУЖАЕМ CHANNEL LOGOS ==========
// Load CHANNEL logos
let channel_sheet = workbook
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
let channels: HashMap<String, String> = channel_sheet
.rows
.iter()
@@ -366,95 +332,42 @@ async fn generate_and_submit_jobs(
.collect();
info!("Loaded {} channel logos", channels.len());
if log_enabled!(Level::Debug) {
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
}
// ========== ОБРАБАТЫВАЕМ START (только активные строки) ==========
// Process START sheet
let start_sheet = workbook
.get_sheet("Start")
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
let total_start_rows = start_sheet.rows.len();
// Сразу фильтруем только строки с STATE = "FALSE"
let active_rows: Vec<(usize, &HashMap<String, String>)> = start_sheet
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE"))
.collect();
info!("Found {} active rows (STATE='FALSE') out of {} total", active_rows.len(), start_sheet.rows.len());
info!(
"Found {} active rows (STATE='FALSE') out of {} total",
active_rows.len(),
total_start_rows
);
// Собираем использованные команды для очистки logos
let mut used_teams: HashSet<(String, String)> = HashSet::new();
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
// Для дебага
let mut missing_teams: HashSet<String> = HashSet::new();
let mut missing_sports: HashSet<String> = HashSet::new();
for (idx, row) in active_rows {
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
let sport = row.get("SPORT").cloned().unwrap_or_default();
// Запоминаем использованные команды
if !team_a.is_empty() {
used_teams.insert((team_a.clone(), sport.clone()));
}
if !team_b.is_empty() {
used_teams.insert((team_b.clone(), sport.clone()));
}
if log_enabled!(Level::Debug) {
let logo_a = logos.find(&team_a, &sport);
let logo_b = logos.find(&team_b, &sport);
if !team_a.is_empty() && logo_a.is_none() {
missing_teams.insert(team_a.clone());
missing_sports.insert(sport.clone());
}
if !team_b.is_empty() && logo_b.is_none() {
missing_teams.insert(team_b.clone());
missing_sports.insert(sport.clone());
}
}
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
jobs.extend(job.create_variants());
}
}
// Очищаем logos от неиспользуемых команд
// Cleanup unused logos
let logos_before = logos.len();
logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string())));
info!(
"Retained {} used team logos (cleaned up {} unused)",
logos.len(),
logos_before - logos.len()
);
info!("Retained {} used team logos (cleaned up {} unused)", logos.len(), logos_before - logos.len());
// Дебаг: выводим сводку по отсутствующим логотипам
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
debug!("=== MISSING LOGOS SUMMARY ===");
debug!("Total unique teams missing logos: {}", missing_teams.len());
debug!("Sports involved: {:?}", missing_sports);
debug!(
"Missing teams (first 20): {:?}",
missing_teams.iter().take(20).collect::<Vec<_>>()
);
debug!("==============================");
}
info!("Generated {} total jobs (including variants)", jobs.len());
// ========== СОРТИРОВКА ПО ДАТЕ И ВРЕМЕНИ ==========
// Sort jobs by date and time
jobs.sort_by(|a, b| match a.sort_date.cmp(&b.sort_date) {
std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) {
std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order),
@@ -462,44 +375,20 @@ async fn generate_and_submit_jobs(
},
other => other,
});
info!("Jobs sorted by date and time (earliest first)");
if log_enabled!(Level::Debug) {
for (i, job) in jobs.iter().take(10).enumerate() {
debug!(
" {}: {} {} - {} (variant: {})",
i + 1,
job.sort_date,
job.sort_time,
job.outfile_name,
job.variant_order
);
}
}
if jobs.is_empty() {
info!("No jobs with STATE='FALSE' found");
return Ok(Vec::new());
}
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
cleanup_finished_jobs(&config.nexrender_api_url).await?;
let http_client = Client::new();
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
for job in jobs {
let nexrender_job = job.to_nexrender_job(config);
info!("Submitting job: {}", job.outfile_name);
if log_enabled!(Level::Debug) {
debug!(
"Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'",
job.sport, job.league, job.channel, job.team_a, job.team_b
);
}
let response = http_client
.post(&config.nexrender_api_url)
.json(&nexrender_job)
@@ -511,6 +400,22 @@ async fn generate_and_submit_jobs(
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
// Save to database
let record = JobRecord {
uid: uid.to_string(),
filename: job.outfile_name.clone(),
status: "queued".to_string(),
output_path: format!("{}/{}.{}", config.output_folder, job.outfile_name, config.template_output_ext),
approved: false,
created_at: Utc::now(),
updated_at: Utc::now(),
};
if let Err(e) = storage::upsert_job(db, &record).await {
error!("Failed to save job to DB: {}", e);
} else {
let _ = ws_tx.send(WsEvent::JobUpdated(record));
}
}
} else {
let status = response.status();
@@ -519,18 +424,13 @@ async fn generate_and_submit_jobs(
}
}
info!(
"Successfully submitted {} jobs to Nexrender",
submitted_jobs.len()
);
info!("Successfully submitted {} jobs to Nexrender", submitted_jobs.len());
Ok(submitted_jobs)
}
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
let client = Client::new();
let response = client.get(api_url).send().await?;
if response.status().is_success() {
let jobs: Vec<Value> = response.json().await?;
for job in jobs {
@@ -557,4 +457,4 @@ pub async fn fetch_all_jobs(api_url: &str) -> Result<Vec<Value>> {
} else {
Ok(Vec::new())
}
}
}

View File

@@ -2,108 +2,56 @@
<html lang="ru">
<head>
<meta charset="utf-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/assets/logo.png">
<!-- CSS -->
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
<title>AE Anons — Управление заданиями</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-container">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
<i class="fas fa-bolt"></i>
</div>
</div>
<div class="logo-area">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo">
<h1>AE Anons</h1>
</div>
<div class="header-controls">
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-circle-half-stroke" id="themeIcon"></i>
<span id="themeText">Auto</span>
<div class="theme-toggle">
<button id="themeToggleBtn" class="theme-toggle-btn" title="Сменить тему">
<i class="fas fa-moon"></i>
</button>
</div>
</div>
<!-- Action Bar -->
<div class="action-bar">
<button class="btn btn-primary" onclick="generateJobs()">
<i class="fas fa-play"></i> Generate New Jobs
<div class="controls">
<button id="generateBtn" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> Generate
</button>
<button class="btn btn-warning" onclick="stopAllJobs()">
<i class="fas fa-stop"></i> Stop All Jobs
<button id="cleanupBtn" class="btn btn-secondary">
<i class="fas fa-trash-alt"></i> Cleanup
</button>
<button class="btn btn-danger" onclick="cleanupJobs()">
<i class="fas fa-trash-alt"></i> Cleanup Finished
<button id="stopAllBtn" class="btn btn-danger">
<i class="fas fa-stop-circle"></i> Stop all
</button>
<button class="btn btn-outline" onclick="refreshJobs()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<div class="status-message" id="statusMessage">
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
<span>Ready</span>
</div>
<span id="statusMsg" class="status-msg"></span>
</div>
<!-- Stats Grid -->
<div class="stats-grid" id="statsGrid"></div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
onkeyup="filterTable()">
</div>
<div class="auto-refresh-badge">
<i class="fas fa-clock"></i>
<span>Auto-refresh: 60s</span>
<span id="refreshCountdown">60</span>s
</div>
</div>
<!-- Table -->
<div class="table-container">
<div class="table-wrapper">
<table id="jobsTable">
<thead>
<tr>
<th data-column="filename" onclick="sortTable('filename')">
Output File <i class="fas fa-sort"></i>
</th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
State <i class="fas fa-sort-up"></i>
</th>
<th data-column="created" onclick="sortTable('created')">
Created <i class="fas fa-sort"></i>
</th>
<th data-column="updated" onclick="sortTable('updated')">
Updated <i class="fas fa-sort"></i>
</th>
<th data-column="uid" onclick="sortTable('uid')">
UID <i class="fas fa-sort"></i>
</th>
<th data-sort="uid">UID <i class="fas fa-sort"></i></th>
<th data-sort="filename">Файл <i class="fas fa-sort"></i></th>
<th data-sort="status">Статус <i class="fas fa-sort"></i></th>
<th>Превью</th>
<th>Апрув</th>
<th data-sort="created_at">Создан <i class="fas fa-sort"></i></th>
</tr>
</thead>
<tbody id="jobsTableBody">
<tbody id="jobsBody">
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
<td colspan="6" class="loading">Загрузка заданий...</td>
</tr>
</tbody>
</table>
@@ -111,384 +59,243 @@
</div>
<script>
// State
let allJobs = [];
let filteredJobs = [];
let isLoading = false;
let autoRefreshTimer = null;
let countdownTimer = null;
let countdownValue = 60;
let currentTheme = 'auto';
// --- Тема (dark/light) с автоопределением ОС и кнопкой переключения ---
(function initTheme() {
const STORAGE_KEY = 'ae_anons_theme';
const btn = document.getElementById('themeToggleBtn');
const icon = btn.querySelector('i');
// Sorting state
let currentSort = { column: 'state', direction: 'asc' };
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
// Theme handling
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function getOSPreference() {
return darkModeMediaQuery.matches ? 'dark' : 'light';
}
function applyTheme(theme) {
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
updateThemeButton(theme);
}
function updateThemeButton(theme) {
const icon = document.getElementById('themeIcon');
const text = document.getElementById('themeText');
if (theme === 'auto') {
icon.className = 'fas fa-circle-half-stroke';
text.textContent = 'Auto';
} else if (theme === 'dark') {
icon.className = 'fas fa-moon';
text.textContent = 'Dark';
} else {
icon.className = 'fas fa-sun';
text.textContent = 'Light';
}
}
function toggleTheme() {
if (currentTheme === 'auto') {
currentTheme = 'light';
} else if (currentTheme === 'light') {
currentTheme = 'dark';
} else {
currentTheme = 'auto';
}
localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme);
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'auto';
currentTheme = savedTheme;
applyTheme(currentTheme);
darkModeMediaQuery.addEventListener('change', (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
// Sorting
function sortTable(column) {
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
updateSortIcons();
sortAndRender();
}
function updateSortIcons() {
document.querySelectorAll('th').forEach(th => {
const column = th.dataset.column;
th.classList.remove('sorted-asc', 'sorted-desc');
const icon = th.querySelector('i');
if (column === currentSort.column) {
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
icon.className = currentSort.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
if (theme === 'dark') {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
} else {
icon.className = 'fas fa-sort';
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
}
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return null;
}
const saved = getSavedTheme();
const initial = saved !== null ? saved : getSystemTheme();
setTheme(initial);
btn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
// Следим за изменением системной темы (если нет сохранённой)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem(STORAGE_KEY) === null) {
setTheme(e.matches ? 'dark' : 'light');
}
});
})();
// --- Основная логика приложения (WebSocket, таблица, кнопки) ---
let ws = null;
let currentJobs = [];
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => console.log('WebSocket connected');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleWsEvent(data);
};
ws.onclose = () => {
console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (err) => console.error('WebSocket error:', err);
}
function sortJobs(jobs) {
const { column, direction } = currentSort;
const multiplier = direction === 'asc' ? 1 : -1;
return [...jobs].sort((a, b) => {
let aVal, bVal;
switch (column) {
case 'filename':
aVal = a.outfile_name || '';
bVal = b.outfile_name || '';
return multiplier * aVal.localeCompare(bVal);
case 'state':
aVal = stateOrder[a.state] || 999;
bVal = stateOrder[b.state] || 999;
return multiplier * (aVal - bVal);
case 'created':
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'updated':
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return multiplier * (bVal - aVal);
case 'uid':
aVal = a.uid || '';
bVal = b.uid || '';
return multiplier * aVal.localeCompare(bVal);
default:
return 0;
}
});
}
function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
filteredJobs : allJobs;
const sorted = sortJobs(jobsToRender);
renderJobs(sorted);
}
// Data fetching
async function refreshJobs() {
if (isLoading) return;
isLoading = true;
try {
setStatus('loading', 'Loading jobs...');
const response = await fetch('/api/jobs');
allJobs = await response.json();
filteredJobs = [];
document.getElementById('filterInput').value = '';
sortAndRender();
updateStats();
setStatus('success', `Loaded ${allJobs.length} jobs`);
} catch (err) {
console.error(err);
setStatus('error', 'Failed to load jobs');
} finally {
isLoading = false;
function handleWsEvent(event) {
switch (event.type) {
case 'JobUpdated':
updateJobInList(event.job);
break;
case 'GenerationStarted':
showStatus('Генерация заданий началась...', 'info');
break;
case 'GenerationFinished':
showStatus(`Генерация завершена: создано ${event.count} заданий`, 'success');
loadJobs();
break;
case 'Error':
showStatus(`Ошибка: ${event.message}`, 'error');
break;
default:
console.log('Unknown event:', event);
}
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
function updateJobInList(job) {
const idx = currentJobs.findIndex(j => j.uid === job.uid);
if (idx !== -1) {
currentJobs[idx] = job;
} else {
currentJobs.unshift(job);
}
renderJobsTable();
}
if (jobs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>No jobs found</p>
</div>
</td>
</tr>
`;
async function loadJobs() {
try {
const response = await fetch('/api/jobs');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
currentJobs = await response.json();
renderJobsTable();
} catch (err) {
console.error('Failed to load jobs:', err);
document.getElementById('jobsBody').innerHTML = '<tr><td colspan="6">Ошибка загрузки заданий</td></tr>';
}
}
function renderJobsTable() {
const tbody = document.getElementById('jobsBody');
if (!currentJobs.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет заданий</td></tr>';
return;
}
tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at);
return `
<tr>
<td>
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
</td>
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td>
<td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
</tr>
`;
}).join('');
tbody.innerHTML = currentJobs.map(job => `
<tr data-uid="${job.uid}">
<td class="uid">${escapeHtml(job.uid)}</td>
<td class="filename">${escapeHtml(job.filename)}</td>
<td class="status">${escapeHtml(job.status)}</td>
<td class="preview">
<a href="/api/video/${job.uid}" target="_blank" title="Просмотр видео">
<i class="fas fa-video"></i>
</a>
</td>
<td class="approve">
<button class="approve-btn" data-uid="${job.uid}" ${job.approved ? 'disabled' : ''}>
${job.approved ? '✅' : '✅'}
</button>
</td>
<td class="created">${formatDate(job.created_at)}</td>
</tr>
`).join('');
// привязать обработчики апрува
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const uid = btn.getAttribute('data-uid');
approveJob(uid, btn);
});
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStateClass(state) {
const classes = {
'finished': 'badge-finished',
'started': 'badge-started',
'processing': 'badge-processing',
'queued': 'badge-queued',
'error': 'badge-error',
'pending': 'badge-pending'
};
return classes[state] || 'badge-pending';
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
async function approveJob(uid, btn) {
try {
const date = new Date(dateStr);
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
return `${time}, ${dayMonth}`;
} catch {
return dateStr;
const response = await fetch(`/api/approve/${uid}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
showStatus(`Файл скопирован: ${result.destination || 'approved'}`, 'success');
btn.disabled = true;
// обновить задание в списке
const job = currentJobs.find(j => j.uid === uid);
if (job) { job.approved = true; renderJobsTable(); }
} else {
showStatus(`Ошибка: ${result.message || result.status}`, 'error');
}
} catch (err) {
showStatus(`Ошибка при апруве: ${err.message}`, 'error');
}
}
function updateStats() {
const stats = {
total: allJobs.length,
finished: 0,
started: 0,
queued: 0,
error: 0,
other: 0
};
allJobs.forEach(job => {
switch (job.state) {
case 'finished': stats.finished++; break;
case 'started':
case 'processing': stats.started++; break;
case 'queued': stats.queued++; break;
case 'error': stats.error++; break;
default: stats.other++; break;
}
});
const grid = document.getElementById('statsGrid');
grid.innerHTML = `
<div class="stat-card">
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
<div class="value">${stats.total}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-check-circle"></i> Completed</h3>
<div class="value">${stats.finished}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-play-circle"></i> Active</h3>
<div class="value">${stats.started + stats.queued}</div>
</div>
<div class="stat-card">
<h3><i class="fas fa-exclamation-circle"></i> Errors</h3>
<div class="value">${stats.error}</div>
</div>
`;
}
function filterTable() {
const filter = document.getElementById('filterInput').value.toLowerCase();
filteredJobs = allJobs.filter(job =>
job.outfile_name.toLowerCase().includes(filter) ||
job.uid.toLowerCase().includes(filter)
);
sortAndRender();
}
// Actions
async function generateJobs() {
setStatus('loading', 'Generating jobs...');
const btn = document.getElementById('generateBtn');
btn.disabled = true;
showStatus('Запуск генерации...', 'info');
try {
const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Job generation started');
setTimeout(() => refreshJobs(), 5000);
showStatus('Генерация запущена', 'success');
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
showStatus(`Ошибка: ${text}`, 'error');
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
async function stopAllJobs() {
if (!confirm('Are you sure you want to stop all active jobs?')) return;
setStatus('loading', 'Stopping all jobs...');
try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
if (response.ok) {
setStatus('success', 'All jobs stopped');
refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
showStatus(`Ошибка: ${err.message}`, 'error');
} finally {
setTimeout(() => { btn.disabled = false; }, 2000);
}
}
async function cleanupJobs() {
setStatus('loading', 'Cleaning up finished jobs...');
showStatus('Очистка завершённых заданий...', 'info');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Cleanup completed');
await refreshJobs();
showStatus('Очистка выполнена', 'success');
loadJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
showStatus('Ошибка при очистке', 'error');
}
} catch (err) {
setStatus('error', `Error: ${err}`);
showStatus(`Ошибка: ${err.message}`, 'error');
}
}
function setStatus(type, message) {
const el = document.getElementById('statusMessage');
const icons = {
loading: '<span class="spinner"></span>',
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
};
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
async function stopAllJobs() {
if (!confirm('Остановить все активные задания?')) return;
showStatus('Остановка заданий...', 'info');
try {
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
const text = await response.text();
showStatus(text, 'success');
loadJobs();
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
}
}
// Auto-refresh
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
}, 1000);
function showStatus(msg, type) {
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.className = `status-msg ${type}`;
setTimeout(() => {
if (el.textContent === msg) el.textContent = '';
}, 5000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (countdownTimer) clearInterval(countdownTimer);
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
}
function resetCountdown() {
countdownValue = 60;
document.getElementById('refreshCountdown').textContent = countdownValue;
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function (m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Initialize
initTheme();
refreshJobs();
startAutoRefresh();
updateSortIcons();
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
// Инициализация
connectWebSocket();
loadJobs();
document.getElementById('generateBtn').addEventListener('click', generateJobs);
document.getElementById('cleanupBtn').addEventListener('click', cleanupJobs);
document.getElementById('stopAllBtn').addEventListener('click', stopAllJobs);
</script>
</body>

View File

@@ -1,555 +1,273 @@
/* ========================================
AE Anons - Nexrender Job Manager Styles
======================================== */
/* CSS Variables - Light Theme (default) */
:root {
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f3f0f7;
--text-primary: #1a1a1a;
--text-secondary: #6b4f7c;
--border-color: #e0d4e8;
--accent-primary: #7c3aed;
--accent-secondary: #c2410c;
--accent-success: #10b981;
--accent-warning: #ea580c;
--accent-danger: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--header-bg: #7c3aed;
--header-text: #ffffff;
--bg-body: #f5f7fa;
--bg-container: #ffffff;
--text-primary: #1e2a3a;
--text-secondary: #4a5568;
--border-color: #e2e8f0;
--table-header-bg: #f8fafc;
--btn-primary-bg: #3b82f6;
--btn-primary-hover: #2563eb;
--btn-secondary-bg: #64748b;
--btn-secondary-hover: #475569;
--btn-danger-bg: #ef4444;
--btn-danger-hover: #dc2626;
--status-info: #3b82f6;
--status-success: #10b981;
--status-error: #ef4444;
--logo-brightness: 1;
}
/* Dark Theme */
[data-theme="dark"] {
--bg-primary: #1a1025;
--bg-secondary: #251a30;
--bg-tertiary: #352545;
--text-primary: #f0e6ff;
--text-secondary: #c4a6d9;
--border-color: #4a3560;
--accent-primary: #a78bfa;
--accent-secondary: #f59e0b;
--accent-success: #34d399;
--accent-warning: #fbbf24;
--accent-danger: #f87171;
--header-bg: #a78bfa;
--header-text: #1a1025;
--bg-body: #0f172a;
--bg-container: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--border-color: #334155;
--table-header-bg: #0f172a;
--btn-primary-bg: #2563eb;
--btn-primary-hover: #3b82f6;
--btn-secondary-bg: #475569;
--btn-secondary-hover: #64748b;
--btn-danger-bg: #dc2626;
--btn-danger-hover: #ef4444;
--logo-brightness: 0.9;
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
transition: background-color 0.3s, color 0.2s;
}
.container {
max-width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
background-color: var(--bg-container);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
}
.header-left {
.logo-area {
display: flex;
align-items: center;
gap: 16px;
}
.logo-container {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--accent-primary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: var(--shadow-md);
gap: 1rem;
}
.logo {
width: 100%;
height: 100%;
object-fit: cover;
width: 150px;
/* уменьшенный размер */
height: auto;
filter: brightness(var(--logo-brightness));
}
.logo-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
.header h1 {
font-size: 28px;
.logo-area h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: var(--accent-primary);
letter-spacing: -0.5px;
}
.header-controls {
display: flex;
align-items: center;
gap: 12px;
}
.theme-toggle {
background: var(--bg-secondary);
border: 2px solid var(--accent-primary);
border-radius: 24px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 14px;
box-shadow: var(--shadow-sm);
}
.theme-toggle:hover {
box-shadow: var(--shadow-md);
}
/* Action Bar */
.action-bar {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.theme-toggle-btn {
background: none;
border: 1px solid var(--border-color);
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
font-size: 1.2rem;
color: var(--text-primary);
transition: all 0.2s;
}
.theme-toggle-btn:hover {
background-color: var(--border-color);
}
.controls {
padding: 1rem 2rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
}
/* Buttons */
.btn {
padding: 10px 20px;
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
opacity: 0;
transition: opacity 0.2s;
}
.btn:hover::before {
opacity: 1;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
gap: 0.5rem;
}
.btn i {
font-size: 14px;
font-size: 1rem;
}
/* Generate - Purple */
.btn-primary {
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
background-color: var(--btn-primary-bg);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
}
[data-theme="dark"] .btn-primary {
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
color: #1a1025;
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
}
.btn-primary:hover {
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
background-color: var(--btn-primary-hover);
}
/* Stop - Orange */
.btn-warning {
background: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
.btn-secondary {
background-color: var(--btn-secondary-bg);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
}
[data-theme="dark"] .btn-warning {
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
color: #1a1025;
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
.btn-secondary:hover {
background-color: var(--btn-secondary-hover);
}
.btn-warning:hover {
box-shadow: 0 4px 12px rgba(234, 88, 12, 0.4);
}
/* Cleanup - Plum */
.btn-danger {
background: linear-gradient(135deg, #9d174d 0%, #be185d 100%);
background-color: var(--btn-danger-bg);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(157, 23, 77, 0.3);
}
[data-theme="dark"] .btn-danger {
background: linear-gradient(135deg, #be185d 0%, #db2777 100%);
color: #f0e6ff;
box-shadow: 0 2px 8px rgba(190, 24, 93, 0.4);
}
.btn-danger:hover {
box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4);
background-color: var(--btn-danger-hover);
}
/* Refresh - Outline */
.btn-outline {
background: transparent;
border: 2px solid #7c3aed;
color: #7c3aed;
box-shadow: none;
.btn:active {
transform: scale(0.97);
}
[data-theme="dark"] .btn-outline {
border-color: #a78bfa;
color: #a78bfa;
}
.btn-outline:hover {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
[data-theme="dark"] .btn-outline:hover {
background: #a78bfa;
color: #1a1025;
border-color: #a78bfa;
}
/* Status Message */
.status-message {
.status-msg {
margin-left: auto;
padding: 8px 16px;
background: var(--bg-tertiary);
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 14px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
.status-msg.info {
background-color: var(--status-info);
color: white;
}
.stat-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
.status-msg.success {
background-color: var(--status-success);
color: white;
}
.stat-card h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 500;
.status-msg.error {
background-color: var(--status-error);
color: white;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: var(--accent-primary);
}
/* Filter Bar */
.filter-bar {
margin-bottom: 20px;
display: flex;
gap: 12px;
align-items: center;
}
.search-wrapper {
flex: 1;
position: relative;
}
.search-wrapper i {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
}
.search-input {
width: 100%;
padding: 12px 16px 12px 44px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 24px;
color: var(--text-primary);
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.auto-refresh-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
border-radius: 20px;
font-size: 13px;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
/* Table */
.table-container {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: var(--shadow-lg);
.table-wrapper {
overflow-x: auto;
border: 1px solid var(--border-color);
padding: 0 2rem 2rem 2rem;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
min-width: 800px;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
text-align: left;
padding: 16px;
background: var(--header-bg);
color: var(--header-text);
background-color: var(--table-header-bg);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
cursor: pointer;
user-select: none;
position: relative;
}
th:hover {
opacity: 0.9;
}
th i {
margin-left: 8px;
font-size: 12px;
opacity: 0.7;
}
th.sorted-asc i,
th.sorted-desc i {
opacity: 1;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
margin-left: 0.5rem;
opacity: 0.5;
}
tr:hover {
background: var(--bg-tertiary);
background-color: rgba(0, 0, 0, 0.02);
}
/* Job Filename */
.job-filename {
font-family: 'Monaco', 'Menlo', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
word-break: break-word;
white-space: normal;
max-width: 100%;
color: var(--text-primary);
[data-theme="dark"] tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Badges */
.badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
display: inline-block;
white-space: nowrap;
}
.badge-finished {
background: rgba(16, 185, 129, 0.15);
color: var(--accent-success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.badge-started,
.badge-processing {
background: rgba(124, 58, 237, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(124, 58, 237, 0.3);
}
.badge-queued {
background: rgba(234, 88, 12, 0.15);
color: var(--accent-warning);
border: 1px solid rgba(234, 88, 12, 0.3);
}
.badge-error {
background: rgba(239, 68, 68, 0.15);
color: var(--accent-danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-pending {
background: rgba(245, 158, 11, 0.15);
color: var(--accent-secondary);
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* Date & Time */
.datetime {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.uid {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
/* Empty State */
.empty-state {
.loading {
text-align: center;
padding: 60px 20px;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 48px;
margin-bottom: 16px;
.approve-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
transition: transform 0.1s;
}
.approve-btn:not(:disabled):hover {
transform: scale(1.1);
}
.approve-btn:disabled {
opacity: 0.5;
color: var(--accent-primary);
cursor: default;
}
/* Loading Spinner */
.spinner {
width: 20px;
height: 20px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
.preview a {
color: var(--text-primary);
text-decoration: none;
font-size: 1.2rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
.preview a:hover {
opacity: 0.7;
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 16px;
.header,
.controls,
.table-wrapper {
padding-left: 1rem;
padding-right: 1rem;
}
.header {
flex-direction: column;
align-items: flex-start;
.logo {
width: 100px;
}
.header-left {
flex-wrap: wrap;
.logo-area h1 {
font-size: 1.2rem;
}
.header h1 {
font-size: 20px;
}
.action-bar {
flex-direction: column;
align-items: stretch;
}
.status-message {
margin-left: 0;
th,
td {
padding: 0.5rem;
}
}

132
src/storage.rs Normal file
View File

@@ -0,0 +1,132 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone, Serialize)]
pub struct JobRecord {
pub uid: String,
pub filename: String,
pub status: String,
pub output_path: String,
pub approved: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
match SqlitePool::connect(database_url).await {
Ok(pool) => {
// Создаём таблицы
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS jobs (
uid TEXT PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL,
output_path TEXT NOT NULL,
approved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
}
Err(e) => {
log::warn!("Failed to open database file '{}': {}", database_url, e);
log::warn!("Falling back to in-memory database. Data will not be persisted!");
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS jobs (
uid TEXT PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL,
output_path TEXT NOT NULL,
approved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
}
}
}
pub async fn upsert_job(pool: &SqlitePool, record: &JobRecord) -> Result<()> {
sqlx::query(
r#"
INSERT INTO jobs (uid, filename, status, output_path, approved, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uid) DO UPDATE SET
status = excluded.status,
output_path = excluded.output_path,
approved = excluded.approved,
updated_at = excluded.updated_at
"#,
)
.bind(&record.uid)
.bind(&record.filename)
.bind(&record.status)
.bind(&record.output_path)
.bind(record.approved)
.bind(record.created_at)
.bind(record.updated_at)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_all_jobs(pool: &SqlitePool) -> Result<Vec<JobRecord>> {
let rows = sqlx::query("SELECT * FROM jobs ORDER BY created_at DESC")
.fetch_all(pool)
.await?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(JobRecord {
uid: row.get("uid"),
filename: row.get("filename"),
status: row.get("status"),
output_path: row.get("output_path"),
approved: row.get("approved"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
});
}
Ok(jobs)
}
pub async fn get_job(pool: &SqlitePool, uid: &str) -> Result<Option<JobRecord>> {
let row = sqlx::query("SELECT * FROM jobs WHERE uid = ?")
.bind(uid)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(JobRecord {
uid: row.get("uid"),
filename: row.get("filename"),
status: row.get("status"),
output_path: row.get("output_path"),
approved: row.get("approved"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}))
} else {
Ok(None)
}
}
pub async fn approve_job(pool: &SqlitePool, uid: &str) -> Result<bool> {
let result = sqlx::query("UPDATE jobs SET approved = 1, updated_at = ? WHERE uid = ?")
.bind(Utc::now())
.bind(uid)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

View File

@@ -1,32 +1,430 @@
use crate::config::Config;
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
use crate::processor::{fetch_all_jobs, process_spreadsheet};
use crate::storage::{self, JobRecord};
use axum::{
extract::State,
extract::{Path, State, WebSocketUpgrade},
http::{header::CONTENT_TYPE, StatusCode},
response::{Html, IntoResponse, Json},
response::{Html, IntoResponse, Json, Response},
routing::{get, post},
Router,
};
use serde::Serialize;
use serde_json::json;
use smb::{Client, ClientConfig, CreateOptions, FileAttributes, FileCreateArgs, UncPath, WriteAt};
use sqlx::SqlitePool;
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tokio::sync::broadcast;
use tokio_util::io::ReaderStream;
use tower_http::trace::TraceLayer;
use url::Url;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
pub db: SqlitePool,
pub ws_tx: broadcast::Sender<WsEvent>,
pub last_generation: Arc<tokio::sync::Mutex<Option<chrono::DateTime<chrono::Local>>>>,
}
#[derive(Serialize)]
pub struct JobInfo {
pub uid: String,
pub outfile_name: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[derive(Serialize, Clone)]
#[serde(tag = "type")]
pub enum WsEvent {
JobUpdated(JobRecord),
GenerationStarted,
GenerationFinished { count: usize },
Error { message: String },
}
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
let web_port = config.web_port;
let db = storage::init_db(&config.database_url).await?;
let (ws_tx, _) = broadcast::channel(100);
let state = AppState {
config,
db,
ws_tx,
last_generation: Arc::new(tokio::sync::Mutex::new(None)),
};
let state_clone_for_sync = state.clone();
tokio::spawn(async move {
sync_jobs_status(state_clone_for_sync).await;
});
let app = Router::new()
.route("/", get(index_page))
.route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_css))
.route("/assets/logo.png", get(logo_png))
.route("/api/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs))
.route("/api/video/{uid}", get(serve_video))
.route("/api/approve/{uid}", post(approve_job))
.route("/ws", get(ws_handler))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
log::info!("Web server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn sync_jobs_status(state: AppState) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
interval.tick().await;
if let Ok(jobs_json) = fetch_all_jobs(&state.config.nexrender_api_url).await {
for job_json in jobs_json {
if let Some(job) = JobInfo::from_nexrender_json(&job_json) {
// Получаем текущую запись из БД
if let Ok(Some(mut record)) = storage::get_job(&state.db, &job.uid).await {
if record.status != job.state {
// Статус изменился обновляем БД и шлём событие
record.status = job.state.clone();
record.updated_at = chrono::Utc::now();
if let Err(e) = storage::upsert_job(&state.db, &record).await {
log::error!("Failed to update job status in DB: {}", e);
} else {
let _ = state.ws_tx.send(WsEvent::JobUpdated(record));
}
}
}
}
}
}
}
}
async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html"))
}
async fn style_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/style.css"),
)
}
async fn fontawesome_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/fontawesome/all.min.css"),
)
}
async fn logo_png() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/png")],
include_bytes!("../assets/logo.png").as_slice(),
)
}
async fn favicon() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/x-icon")],
include_bytes!("static/favicon.ico").as_slice(),
)
}
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobRecord>>, AppError> {
let jobs = storage::get_all_jobs(&state.db)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(jobs))
}
async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let mut last_gen = state.last_generation.lock().await;
if let Some(last) = *last_gen {
let elapsed = chrono::Local::now().signed_duration_since(last);
if elapsed.num_seconds() < 5 {
return Err(AppError(
StatusCode::TOO_MANY_REQUESTS,
"Generation already in progress or too recent".to_string(),
));
}
}
*last_gen = Some(chrono::Local::now());
drop(last_gen);
let state_clone = state.clone();
tokio::spawn(async move {
let _ = state_clone.ws_tx.send(WsEvent::GenerationStarted);
match process_spreadsheet(
&state_clone.config,
&state_clone.db,
state_clone.ws_tx.clone(),
)
.await
{
Ok(submitted) => {
log::info!("Generation completed, {} jobs submitted", submitted.len());
let _ = state_clone.ws_tx.send(WsEvent::GenerationFinished {
count: submitted.len(),
});
}
Err(e) => {
log::error!("Generation failed: {}", e);
let _ = state_clone.ws_tx.send(WsEvent::Error {
message: e.to_string(),
});
}
}
*state_clone.last_generation.lock().await = None;
});
Ok((StatusCode::ACCEPTED, "Job generation started"))
}
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let client = reqwest::Client::new();
let api_url = &state.config.nexrender_api_url;
let jobs = fetch_all_jobs(api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut removed = 0;
for job in jobs {
if let (Some(uid), Some(status)) = (
job.get("uid").and_then(|u| u.as_str()),
job.get("state").and_then(|s| s.as_str()),
) {
if status == "finished" || status == "error" {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
// Also delete from local DB
let _ = sqlx::query("DELETE FROM jobs WHERE uid = ?")
.bind(uid)
.execute(&state.db)
.await;
removed += 1;
log::info!("Cleaned up job: {}", uid);
}
}
}
Ok((StatusCode::OK, format!("Cleaned up {} jobs", removed)))
}
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let last_gen = *state.last_generation.lock().await;
let status = json!({
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
"nexrender_api": state.config.nexrender_api_url,
});
Ok(Json(status))
}
async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
use reqwest::Client;
let client = Client::new();
let api_url = &state.config.nexrender_api_url;
let jobs = fetch_all_jobs(api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut stopped = 0;
for job in jobs {
let state_str = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state_str == "queued" || state_str == "started" || state_str == "processing" {
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
stopped += 1;
log::info!("Stopped job: {}", uid);
}
}
}
log::info!("Stopped {} active jobs", stopped);
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
}
async fn serve_video(
Path(uid): Path<String>,
State(state): State<AppState>,
) -> Result<Response, AppError> {
let record = storage::get_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
let path = std::path::Path::new(&record.output_path);
if !path.exists() {
return Err(AppError(
StatusCode::NOT_FOUND,
"File not found".to_string(),
));
}
let mime = mime_guess::from_path(path).first_or_octet_stream();
let file = tokio::fs::File::open(path)
.await
.map_err(|_| AppError(StatusCode::NOT_FOUND, "Cannot open file".to_string()))?;
let stream = ReaderStream::new(file);
let body = axum::body::Body::from_stream(stream);
Ok(Response::builder()
.header(CONTENT_TYPE, mime.as_ref())
.body(body)
.unwrap())
}
async fn approve_job(
Path(uid): Path<String>,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut record = storage::get_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
if record.approved {
return Ok(Json(json!({"status": "already_approved"})));
}
let smb_url = state.config.smb_upload_url.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_UPLOAD_URL not configured".to_string(),
)
})?;
let smb_user = state.config.smb_username.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_USERNAME not configured".to_string(),
)
})?;
let smb_pass = state.config.smb_password.as_ref().ok_or_else(|| {
AppError(
StatusCode::BAD_REQUEST,
"SMB_PASSWORD not configured".to_string(),
)
})?;
let _smb_workgroup = state.config.smb_workgroup.clone().unwrap_or_default();
// *** ИСПРАВЛЕНИЕ ЗДЕСЬ ***
let src_path = std::path::Path::new(&state.config.smb_source_folder).join(&record.filename);
if !src_path.exists() {
return Err(AppError(
StatusCode::NOT_FOUND,
format!("Source file not found: {}", src_path.display()),
));
}
let url = Url::parse(smb_url)
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid SMB URL: {}", e)))?;
let server = url
.host_str()
.ok_or_else(|| AppError(StatusCode::BAD_REQUEST, "No host in SMB URL".to_string()))?;
let share = url
.path()
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("");
if share.is_empty() {
return Err(AppError(
StatusCode::BAD_REQUEST,
"No share in SMB URL".to_string(),
));
}
let client = Client::new(ClientConfig::default());
let target_path = UncPath::from_str(&format!("\\\\{}\\{}", server, share))
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid UNC path: {}", e)))?;
client
.share_connect(&target_path, smb_user, smb_pass.clone())
.await
.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("SMB connect failed: {}", e),
)
})?;
let data = tokio::fs::read(&src_path).await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Read source failed: {}", e),
)
})?;
let remote_path = url.path().trim_start_matches('/');
let remote_filename = if remote_path.is_empty() {
record.filename.clone()
} else {
format!("{}/{}", remote_path, record.filename)
};
let file_to_open = target_path.with_path(&remote_filename);
let file_open_args =
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
let resource = client
.create_file(&file_to_open, &file_open_args)
.await
.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Create file failed: {}", e),
)
})?;
let remote_file = resource.unwrap_file();
remote_file.write_at(&data, 0).await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Write to SMB failed: {}", e),
)
})?;
remote_file.close().await.map_err(|e| {
AppError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Close file failed: {}", e),
)
})?;
storage::approve_job(&state.db, &uid)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
record.approved = true;
let _ = state.ws_tx.send(WsEvent::JobUpdated(record));
Ok(Json(json!({"status": "approved"})))
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.ws_tx.subscribe();
while let Ok(event) = rx.recv().await {
if let Ok(text) = serde_json::to_string(&event) {
if socket
.send(axum::extract::ws::Message::Text(text.into()))
.await
.is_err()
{
break;
}
}
}
}
#[derive(Debug, Clone)]
struct JobInfo {
uid: String,
outfile_name: String,
state: String,
}
impl JobInfo {
@@ -65,174 +463,14 @@ impl JobInfo {
uid,
outfile_name,
state,
created_at: job
.get("createdAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
updated_at: job
.get("updatedAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
})
}
}
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
let web_port = config.web_port;
let state = AppState {
config,
last_generation: Arc::new(Mutex::new(None)),
};
let app = Router::new()
.route("/", get(index_page))
.route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_css))
.route("/assets/logo.png", get(logo_png))
.route("/api/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs))
.nest_service(
"/static/fontawesome/webfonts",
ServeDir::new("src/static/fontawesome/webfonts"),
)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
log::info!("Web server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn index_page() -> Html<&'static str> {
Html(include_str!("static/index.html"))
}
async fn style_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/style.css"),
)
}
async fn fontawesome_css() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/css")],
include_str!("static/fontawesome/all.min.css"),
)
}
async fn logo_png() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/png")],
include_bytes!("../assets/logo.png").as_slice(),
)
}
async fn favicon() -> impl IntoResponse {
(
[(CONTENT_TYPE, "image/x-icon")],
include_bytes!("static/favicon.ico").as_slice(),
)
}
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let jobs: Vec<JobInfo> = jobs_json
.iter()
.filter_map(JobInfo::from_nexrender_json)
.collect();
Ok(Json(jobs))
}
async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let mut last_gen = state.last_generation.lock().await;
if let Some(last) = *last_gen {
let elapsed = chrono::Local::now().signed_duration_since(last);
if elapsed.num_seconds() < 5 {
return Err(AppError(
StatusCode::TOO_MANY_REQUESTS,
"Generation already in progress or too recent".to_string(),
));
}
}
*last_gen = Some(chrono::Local::now());
drop(last_gen);
let config = state.config.clone();
let last_gen_clone = state.last_generation.clone();
tokio::spawn(async move {
match process_spreadsheet(&config).await {
Ok(submitted) => {
log::info!("Generation completed, {} jobs submitted", submitted.len());
}
Err(e) => {
log::error!("Generation failed: {}", e);
}
}
*last_gen_clone.lock().await = None;
});
Ok((StatusCode::ACCEPTED, "Job generation started"))
}
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
cleanup_finished_jobs(&state.config.nexrender_api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::OK, "Cleanup completed"))
}
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let last_gen = *state.last_generation.lock().await;
let status = serde_json::json!({
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
"nexrender_api": state.config.nexrender_api_url,
});
Ok(Json(status))
}
async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
use reqwest::Client;
let client = Client::new();
let api_url = &state.config.nexrender_api_url;
let jobs = fetch_all_jobs(api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut stopped = 0;
for job in jobs {
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state == "queued" || state == "started" || state == "processing" {
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
stopped += 1;
log::info!("Stopped job: {}", uid);
}
}
}
log::info!("Stopped {} active jobs", stopped);
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
}
struct AppError(StatusCode, String);
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
fn into_response(self) -> Response {
(self.0, self.1).into_response()
}
}