Release v0.2.9: runtime reliability and admin UX refresh

- ship runtime reliability, watcher/scanner hardening, and hardware hot reload

- refresh admin/settings UX and add Playwright reliability coverage

- standardize frontend workflow on Bun and update deploy/docs
This commit is contained in:
2026-03-05 22:22:06 -05:00
parent adb034d850
commit 095b648757
66 changed files with 4725 additions and 5859 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ alchemist.db
web/node_modules
web/dist
web/.astro
web/package-lock.json
web.zip
.DS_Store
alchemist.db-shm

View File

@@ -2,6 +2,11 @@
All notable changes to this project will be documented in this file.
## [v0.2.9] - 2026-03-06
- Runtime reliability pass: watcher/scanner hardening, resilient event consumers, config reload improvements, and live hardware refresh.
- Admin UX refresh across dashboard, settings, setup, logs, jobs, charts, and system status with stronger error handling and feedback.
- Frontend workflow standardized on Bun, Playwright reliability coverage added under `web-e2e`, and deploy/docs/container updates shipped together.
## [v0.2.8] - 2026-01-12
- Setup wizard auth fixes, scheduler time validation, and watcher reliability improvements.
- DB stability pass (WAL, FK enforcement, indexes, session cleanup, legacy watch_dirs compatibility).

4
Cargo.lock generated
View File

@@ -26,7 +26,7 @@ dependencies = [
[[package]]
name = "alchemist"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"anyhow",
"argon2",
@@ -35,6 +35,7 @@ dependencies = [
"chrono",
"clap",
"futures",
"http-body-util",
"inquire",
"mime_guess",
"notify",
@@ -52,6 +53,7 @@ dependencies = [
"tokio",
"tokio-stream",
"toml",
"tower",
"tracing",
"tracing-subscriber",
"uuid",

View File

@@ -1,6 +1,6 @@
[package]
name = "alchemist"
version = "0.2.8"
version = "0.2.9"
edition = "2021"
license = "GPL-3.0"
build = "build.rs"
@@ -45,3 +45,7 @@ rand = "0.8"
sysinfo = "0.32"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
[dev-dependencies]
http-body-util = "0.1"
tower = { version = "0.5", features = ["util"] }

5
Dockerfile vendored
View File

@@ -1,7 +1,7 @@
# Stage 1: Build Frontend with Bun
FROM oven/bun:1 AS frontend-builder
WORKDIR /app
COPY web/package.json web/bun.lockb* ./
COPY web/package.json web/bun.lock* ./
RUN bun install --frozen-lockfile
COPY web/ .
RUN bun run build
@@ -30,6 +30,7 @@ RUN cargo build --release
# Stage 4: Runtime
FROM debian:testing-slim AS runtime
WORKDIR /app
RUN mkdir -p /app/config /app/data
# Install runtime dependencies
RUN apt-get update && \
@@ -66,6 +67,8 @@ COPY --from=builder /app/target/release/alchemist /usr/local/bin/alchemist
# Set environment variables
ENV LIBVA_DRIVER_NAME=iHD
ENV RUST_LOG=info
ENV ALCHEMIST_CONFIG_PATH=/app/config/config.toml
ENV ALCHEMIST_DB_PATH=/app/data/alchemist.db
EXPOSE 3000
ENTRYPOINT ["alchemist"]

View File

@@ -46,8 +46,13 @@ docker build -t alchemist .
# Run the container
docker run -d \
-p 3000:3000 \
-v /path/to/config.toml:/app/config/config.toml:ro \
-v /path/to/media:/media \
-v /path/to/output:/output \
-v /path/to/data:/app/data \
-e ALCHEMIST_CONFIG_PATH=/app/config/config.toml \
-e ALCHEMIST_DB_PATH=/app/data/alchemist.db \
-e ALCHEMIST_CONFIG_MUTABLE=false \
--name alchemist \
alchemist
```
@@ -77,6 +82,12 @@ directories = [ # Auto-scan directories
]
```
Runtime environment variables:
- `ALCHEMIST_CONFIG_PATH` config file path (default: `./config.toml`)
- `ALCHEMIST_DB_PATH` SQLite database path (default: `./alchemist.db`)
- `ALCHEMIST_CONFIG_MUTABLE` allow runtime config writes (`true`/`false`, default: `true`)
## Supported Platforms
- **Linux**: x86_64 (Docker & Binary)

View File

@@ -1 +1 @@
0.2.8
0.2.9

View File

@@ -51,7 +51,7 @@ spec:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /app/config.toml
mountPath: {{ .Values.runtime.configPath | quote }}
subPath: config.toml
- name: data
mountPath: /app/data
@@ -62,6 +62,12 @@ spec:
env:
- name: RUST_LOG
value: "info"
- name: ALCHEMIST_CONFIG_PATH
value: {{ .Values.runtime.configPath | quote }}
- name: ALCHEMIST_DB_PATH
value: {{ .Values.runtime.dbPath | quote }}
- name: ALCHEMIST_CONFIG_MUTABLE
value: {{ .Values.runtime.configMutable | quote }}
volumes:
- name: config
configMap:

View File

@@ -85,3 +85,8 @@ config:
nodeSelector: {}
tolerations: []
affinity: {}
runtime:
configPath: /app/config/config.toml
dbPath: /app/data/alchemist.db
configMutable: false

View File

@@ -7,7 +7,7 @@ services:
- "3000:3000"
volumes:
# Configuration file
- ./config.toml:/app/config.toml:ro
- ./config.toml:/app/config/config.toml:ro
# Media directories (adjust paths as needed)
- /path/to/media:/media
- /path/to/output:/output
@@ -16,6 +16,9 @@ services:
environment:
- RUST_LOG=info
- TZ=America/New_York
- ALCHEMIST_CONFIG_PATH=/app/config/config.toml
- ALCHEMIST_DB_PATH=/app/data/alchemist.db
- ALCHEMIST_CONFIG_MUTABLE=false
# For Intel QuickSync (uncomment if needed)
# devices:
# - /dev/dri:/dev/dri

View File

@@ -203,7 +203,9 @@ On first launch, Alchemist runs an interactive setup wizard to:
## Configuration
Configuration is stored in `config.toml`. On first run, the setup wizard creates this file.
Configuration is stored at `config.toml` by default. Set `ALCHEMIST_CONFIG_PATH` to override the path.
Database is stored at `alchemist.db` by default. Set `ALCHEMIST_DB_PATH` to override the path.
If `ALCHEMIST_CONFIG_MUTABLE=false`, settings/setup endpoints will return HTTP `409` for config write attempts.
### Full Configuration Reference
@@ -291,9 +293,11 @@ host = "0.0.0.0"
| Variable | Description | Default |
|----------|-------------|---------|
| `ALCHEMIST_CONFIG` | Path to config file | `./config.toml` |
| `ALCHEMIST_DATA_DIR` | Data directory path | `./data` |
| `ALCHEMIST_LOG_LEVEL` | Log verbosity | `info` |
| `ALCHEMIST_CONFIG_PATH` | Primary config file path | `./config.toml` |
| `ALCHEMIST_CONFIG` | Legacy alias for config path | unset |
| `ALCHEMIST_DB_PATH` | SQLite database file path | `./alchemist.db` |
| `ALCHEMIST_DATA_DIR` | Legacy data dir fallback for DB (`<dir>/alchemist.db`) | unset |
| `ALCHEMIST_CONFIG_MUTABLE` | Enable/disable runtime config writes | `true` |
---

View File

@@ -44,6 +44,6 @@ try {
Write-Error "Failed to download or install FFmpeg: $_"
} finally {
if (Test-Path $tempDir) {
Remove-Item -Recurit -Force $tempDir
Remove-Item -Recurse -Force $tempDir
}
}

View File

@@ -371,7 +371,7 @@ impl Db {
input_path: &Path,
output_path: &Path,
mtime: std::time::SystemTime,
) -> Result<()> {
) -> Result<bool> {
if input_path == output_path {
return Err(crate::error::AlchemistError::Config(
"Output path matches input path".into(),
@@ -390,7 +390,7 @@ impl Db {
Err(_) => "0.0".to_string(), // Fallback for very old files/clocks
};
sqlx::query(
let result = sqlx::query(
"INSERT INTO jobs (input_path, output_path, status, mtime_hash, updated_at)
VALUES (?, ?, 'queued', ?, CURRENT_TIMESTAMP)
ON CONFLICT(input_path) DO UPDATE SET
@@ -406,7 +406,7 @@ impl Db {
.execute(&self.pool)
.await?;
Ok(())
Ok(result.rows_affected() > 0)
}
pub async fn add_job(&self, job: Job) -> Result<()> {
@@ -828,6 +828,15 @@ impl Db {
Ok(job)
}
pub async fn has_job_with_output_path(&self, path: &str) -> Result<bool> {
let row: Option<(i64,)> =
sqlx::query_as("SELECT 1 FROM jobs WHERE output_path = ? LIMIT 1")
.bind(path)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
pub async fn add_watch_dir(&self, path: &str, is_recursive: bool) -> Result<WatchDir> {
let has_is_recursive = self.has_column("watch_dirs", "is_recursive").await?;
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
@@ -1383,6 +1392,32 @@ mod tests {
assert!(settings.should_replace_existing_output());
}
#[tokio::test]
async fn test_enqueue_job_reports_change_state(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut db_path = std::env::temp_dir();
let token: u64 = rand::random();
db_path.push(format!("alchemist_enqueue_test_{}.db", token));
let db = Db::new(db_path.to_string_lossy().as_ref()).await?;
let input = Path::new("input.mkv");
let output = Path::new("output.mkv");
let changed = db
.enqueue_job(input, output, SystemTime::UNIX_EPOCH)
.await?;
assert!(changed);
let unchanged = db
.enqueue_job(input, output, SystemTime::UNIX_EPOCH)
.await?;
assert!(!unchanged);
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
#[tokio::test]
async fn test_claim_next_job_marks_analyzing(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
@@ -1394,12 +1429,14 @@ mod tests {
let input1 = Path::new("input1.mkv");
let output1 = Path::new("output1.mkv");
db.enqueue_job(input1, output1, SystemTime::UNIX_EPOCH)
let _ = db
.enqueue_job(input1, output1, SystemTime::UNIX_EPOCH)
.await?;
let input2 = Path::new("input2.mkv");
let output2 = Path::new("output2.mkv");
db.enqueue_job(input2, output2, SystemTime::UNIX_EPOCH)
let _ = db
.enqueue_job(input2, output2, SystemTime::UNIX_EPOCH)
.await?;
let first = db

View File

@@ -4,6 +4,7 @@ pub mod error;
pub mod media;
pub mod notifications;
pub mod orchestrator;
pub mod runtime;
pub mod scheduler;
pub mod server;
pub mod system;

View File

@@ -1,9 +1,9 @@
use alchemist::error::Result;
use alchemist::system::hardware;
use alchemist::{config, db, Agent, Transcoder};
use alchemist::{config, db, runtime, Agent, Transcoder};
use clap::Parser;
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tracing::{debug, error, info, warn};
@@ -28,9 +28,6 @@ struct Args {
#[arg(short, long)]
dry_run: bool,
/// Output directory (optional, defaults to same as input with .av1)
#[arg(short, long)]
output_dir: Option<PathBuf>,
/// Reset admin user/password and sessions (forces setup mode)
#[arg(long)]
reset_auth: bool,
@@ -63,6 +60,85 @@ async fn main() -> Result<()> {
}
}
async fn persist_log_events(db: Arc<db::Db>, mut rx: broadcast::Receiver<db::AlchemistEvent>) {
loop {
match rx.recv().await {
Ok(db::AlchemistEvent::Log {
level,
job_id,
message,
}) => {
if let Err(e) = db.add_log(&level, job_id, &message).await {
eprintln!("Failed to persist log: {e}");
}
}
Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!("Log persistence lagged; skipped {skipped} events");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
async fn persist_progress_events(db: Arc<db::Db>, mut rx: broadcast::Receiver<db::AlchemistEvent>) {
let mut last: HashMap<i64, (f64, Instant)> = HashMap::new();
let min_step = 0.5;
let min_interval = std::time::Duration::from_secs(2);
loop {
match rx.recv().await {
Ok(db::AlchemistEvent::Progress {
job_id, percentage, ..
}) => {
let pct = percentage.clamp(0.0, 100.0);
let now = Instant::now();
let should_update = match last.get(&job_id) {
Some((last_pct, last_time)) => {
pct >= *last_pct + min_step
|| now.duration_since(*last_time) >= min_interval
}
None => true,
};
if should_update {
let _ = db.update_job_progress(job_id, pct).await;
last.insert(job_id, (pct, now));
}
}
Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!("Progress persistence lagged; skipped {skipped} events");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
async fn apply_reloaded_config(
config_path: &Path,
config_state: &Arc<RwLock<config::Config>>,
agent: &Arc<Agent>,
hardware_state: &hardware::HardwareState,
) -> Result<hardware::HardwareInfo> {
let new_config = config::Config::load(config_path)
.map_err(|err| alchemist::error::AlchemistError::Config(err.to_string()))?;
let detected_hardware = hardware::detect_hardware_for_config(&new_config).await?;
let new_limit = new_config.transcode.concurrent_jobs;
{
let mut config_guard = config_state.write().await;
*config_guard = new_config;
}
hardware_state
.replace(Some(detected_hardware.clone()))
.await;
agent.set_concurrent_jobs(new_limit).await;
Ok(detected_hardware)
}
async fn run() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt()
@@ -102,11 +178,10 @@ async fn run() -> Result<()> {
let args = Args::parse();
info!(
target: "startup",
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, output_dir={:?}, directories={}",
"Parsed CLI args: cli_mode={}, reset_auth={}, dry_run={}, directories={}",
args.cli,
args.reset_auth,
args.dry_run,
args.output_dir,
args.directories.len()
);
@@ -120,13 +195,16 @@ async fn run() -> Result<()> {
// 0. Load Configuration
let config_start = Instant::now();
let config_path = std::path::Path::new("config.toml");
let config_path = runtime::config_path();
let db_path = runtime::db_path();
let config_mutable = runtime::config_mutable();
let config_exists = config_path.exists();
let (config, mut setup_mode) = if !config_exists {
let cwd = std::env::current_dir().ok();
info!(
target: "startup",
"config.toml not found (cwd={:?})",
"Config file not found at {:?} (cwd={:?})",
config_path,
cwd
);
if is_server_mode {
@@ -138,10 +216,13 @@ async fn run() -> Result<()> {
(config::Config::default(), false)
}
} else {
match config::Config::load(config_path) {
match config::Config::load(config_path.as_path()) {
Ok(c) => (c, false),
Err(e) => {
warn!("Failed to load config.toml: {}. Using defaults.", e);
warn!(
"Failed to load config file at {:?}: {}. Using defaults.",
config_path, e
);
if is_server_mode {
warn!("Config load failed in server mode. Entering Setup Mode (Web UI).");
(config::Config::default(), true)
@@ -153,18 +234,26 @@ async fn run() -> Result<()> {
};
info!(
target: "startup",
"Config loaded (exists={} setup_mode={}) in {} ms",
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
config_path,
config_exists,
config_mutable,
setup_mode,
config_start.elapsed().as_millis()
);
// 1. Initialize Database
let db_start = Instant::now();
let db = Arc::new(db::Db::new("alchemist.db").await?);
if let Some(parent) = db_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent).map_err(alchemist::error::AlchemistError::Io)?;
}
}
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
info!(
target: "startup",
"Database initialized in {} ms",
"Database initialized at {:?} in {} ms",
db_path,
db_start.elapsed().as_millis()
);
if args.reset_auth {
@@ -247,7 +336,10 @@ async fn run() -> Result<()> {
if !config.hardware.allow_cpu_encoding {
// In setup mode, we might not have set this yet, so don't error out.
error!("CPU encoding is disabled in configuration.");
error!("Set hardware.allow_cpu_encoding = true in config.toml to enable CPU fallback.");
error!(
"Set hardware.allow_cpu_encoding = true in {:?} to enable CPU fallback.",
config_path
);
return Err(alchemist::error::AlchemistError::Config(
"CPU encoding disabled".into(),
));
@@ -267,13 +359,14 @@ async fn run() -> Result<()> {
notification_manager.start_listener(tx.subscribe());
let transcoder = Arc::new(Transcoder::new());
let hardware_state = hardware::HardwareState::new(Some(hw_info.clone()));
let config = Arc::new(RwLock::new(config));
let agent = Arc::new(
Agent::new(
db.clone(),
transcoder.clone(),
config.clone(),
Some(hw_info),
hardware_state.clone(),
tx.clone(),
args.dry_run,
)
@@ -302,52 +395,16 @@ async fn run() -> Result<()> {
// Start Log Persistence Task
let log_db = db.clone();
let mut log_rx = tx.subscribe();
let log_rx = tx.subscribe();
tokio::spawn(async move {
while let Ok(event) = log_rx.recv().await {
if let alchemist::db::AlchemistEvent::Log {
level,
job_id,
message,
..
} = event
{
if let Err(e) = log_db.add_log(&level, job_id, &message).await {
eprintln!("Failed to persist log: {}", e);
}
}
}
persist_log_events(log_db, log_rx).await;
});
// Persist progress so the UI can render accurate job updates.
let progress_db = db.clone();
let mut progress_rx = tx.subscribe();
let progress_rx = tx.subscribe();
tokio::spawn(async move {
let mut last: HashMap<i64, (f64, Instant)> = HashMap::new();
let min_step = 0.5;
let min_interval = std::time::Duration::from_secs(2);
while let Ok(event) = progress_rx.recv().await {
if let alchemist::db::AlchemistEvent::Progress {
job_id, percentage, ..
} = event
{
let pct = percentage.clamp(0.0, 100.0);
let now = Instant::now();
let should_update = match last.get(&job_id) {
Some((last_pct, last_time)) => {
pct >= *last_pct + min_step
|| now.duration_since(*last_time) >= min_interval
}
None => true,
};
if should_update {
let _ = progress_db.update_job_progress(job_id, pct).await;
last.insert(job_id, (pct, now));
}
}
}
persist_progress_events(progress_db, progress_rx).await;
});
// Initialize File Watcher
@@ -437,6 +494,8 @@ async fn run() -> Result<()> {
let config_watcher_arc = config.clone();
let reload_watcher_clone = reload_watcher.clone();
let agent_for_config = agent.clone();
let hardware_state_for_config = hardware_state.clone();
let config_watch_path = config_path.clone();
// Channel for file events
let (tx_notify, mut rx_notify) = tokio::sync::mpsc::unbounded_channel();
@@ -452,11 +511,10 @@ async fn run() -> Result<()> {
match watcher_res {
Ok(mut watcher) => {
if let Err(e) = watcher.watch(
std::path::Path::new("config.toml"),
RecursiveMode::NonRecursive,
) {
error!("Failed to watch config.toml: {}", e);
if let Err(e) =
watcher.watch(config_watch_path.as_path(), RecursiveMode::NonRecursive)
{
error!("Failed to watch config file {:?}: {}", config_watch_path, e);
} else {
// Prevent watcher from dropping by keeping it in the spawn if needed,
// or just spawning the processing loop.
@@ -472,23 +530,25 @@ async fn run() -> Result<()> {
info!("Config file changed. Reloading...");
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
match config::Config::load(std::path::Path::new(
"config.toml",
)) {
Ok(new_config) => {
let new_limit = new_config.transcode.concurrent_jobs;
{
let mut w = config_watcher_arc.write().await;
*w = new_config;
}
match apply_reloaded_config(
config_watch_path.as_path(),
&config_watcher_arc,
&agent_for_config,
&hardware_state_for_config,
)
.await
{
Ok(detected_hardware) => {
info!("Configuration reloaded successfully.");
agent_for_config.set_concurrent_jobs(new_limit).await;
// Trigger watcher update (merges DB + New Config)
info!(
"Runtime hardware reloaded: {}",
detected_hardware.vendor
);
reload_watcher_clone(false).await;
}
Err(e) => error!("Failed to reload config: {}", e),
Err(e) => {
error!("Failed to reload config: {}", e);
}
}
}
}
@@ -510,6 +570,9 @@ async fn run() -> Result<()> {
transcoder,
tx,
setup_required: setup_mode,
config_path: config_path.clone(),
config_mutable,
hardware_state,
notification_manager: notification_manager.clone(),
file_watcher,
})
@@ -517,7 +580,10 @@ async fn run() -> Result<()> {
} else {
// CLI Mode
if setup_mode {
error!("Configuration required. Run without --cli to use the web-based setup wizard, or create config.toml manually.");
error!(
"Configuration required. Run without --cli to use the web-based setup wizard, or create {:?} manually.",
config_path
);
// CLI early exit - error
// (Caller will handle pause-on-exit if needed)
@@ -561,3 +627,170 @@ async fn run() -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::Path;
use std::time::SystemTime;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut db_path = std::env::temp_dir();
db_path.push(format!("{prefix}_{}.db", rand::random::<u64>()));
db_path
}
fn temp_config_path(prefix: &str) -> PathBuf {
let mut config_path = std::env::temp_dir();
config_path.push(format!("{prefix}_{}.toml", rand::random::<u64>()));
config_path
}
#[test]
fn args_reject_removed_output_dir_flag() {
assert!(Args::try_parse_from(["alchemist", "--output-dir", "/tmp/out"]).is_err());
}
#[tokio::test]
async fn log_persistence_worker_survives_lag(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let db_path = temp_db_path("alchemist_log_worker");
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
let (tx, rx) = broadcast::channel(1);
tx.send(db::AlchemistEvent::Log {
level: "info".to_string(),
job_id: None,
message: "first".to_string(),
})?;
tx.send(db::AlchemistEvent::Log {
level: "info".to_string(),
job_id: None,
message: "second".to_string(),
})?;
tx.send(db::AlchemistEvent::Log {
level: "info".to_string(),
job_id: None,
message: "third".to_string(),
})?;
drop(tx);
persist_log_events(db.clone(), rx).await;
let logs = db.get_logs(10, 0).await?;
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].message, "third");
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
#[tokio::test]
async fn progress_persistence_worker_survives_lag(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let db_path = temp_db_path("alchemist_progress_worker");
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
let _ = db
.enqueue_job(
Path::new("input.mkv"),
Path::new("output.mkv"),
SystemTime::UNIX_EPOCH,
)
.await?;
let job = db.get_job_by_input_path("input.mkv").await?.unwrap();
let (tx, rx) = broadcast::channel(1);
tx.send(db::AlchemistEvent::Progress {
job_id: job.id,
percentage: 10.0,
time: "00:00:01".to_string(),
})?;
tx.send(db::AlchemistEvent::Progress {
job_id: job.id,
percentage: 55.0,
time: "00:00:05".to_string(),
})?;
tx.send(db::AlchemistEvent::Progress {
job_id: job.id,
percentage: 80.0,
time: "00:00:08".to_string(),
})?;
drop(tx);
persist_progress_events(db.clone(), rx).await;
let updated = db.get_job(job.id).await?.unwrap();
assert_eq!(updated.progress, 80.0);
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
#[tokio::test]
async fn config_reload_refreshes_runtime_hardware_state(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let db_path = temp_db_path("alchemist_config_reload");
let config_path = temp_config_path("alchemist_config_reload");
let db = Arc::new(db::Db::new(db_path.to_string_lossy().as_ref()).await?);
let initial_config = config::Config::default();
initial_config.save(&config_path)?;
let config_state = Arc::new(RwLock::new(initial_config.clone()));
let hardware_state = hardware::HardwareState::new(Some(hardware::HardwareInfo {
vendor: hardware::Vendor::Nvidia,
device_path: None,
supported_codecs: vec!["av1".to_string()],
}));
let transcoder = Arc::new(Transcoder::new());
let (tx, _rx) = broadcast::channel(8);
let agent = Arc::new(
Agent::new(
db.clone(),
transcoder,
config_state.clone(),
hardware_state.clone(),
tx,
true,
)
.await,
);
let mut reloaded_config = initial_config;
reloaded_config.hardware.preferred_vendor = Some("cpu".to_string());
reloaded_config.hardware.allow_cpu_fallback = true;
reloaded_config.hardware.allow_cpu_encoding = true;
reloaded_config.transcode.concurrent_jobs = 2;
reloaded_config.save(&config_path)?;
let detected = apply_reloaded_config(
config_path.as_path(),
&config_state,
&agent,
&hardware_state,
)
.await?;
assert_eq!(detected.vendor, hardware::Vendor::Cpu);
assert_eq!(
hardware_state.snapshot().await.unwrap().vendor,
hardware::Vendor::Cpu
);
let config_guard = config_state.read().await;
assert_eq!(
config_guard.hardware.preferred_vendor.as_deref(),
Some("cpu")
);
assert_eq!(config_guard.transcode.concurrent_jobs, 2);
drop(config_guard);
drop(agent);
drop(db);
let _ = std::fs::remove_file(config_path);
let _ = std::fs::remove_file(db_path);
Ok(())
}
}

View File

@@ -205,6 +205,12 @@ impl AnalyzerTrait for FfmpegAnalyzer {
pub struct Analyzer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputProbe {
pub codec_name: String,
pub encoder_tag: Option<String>,
}
impl Analyzer {
/// Async version of probe that doesn't block the runtime
pub async fn probe_async(path: &Path) -> Result<FfprobeMetadata> {
@@ -272,6 +278,69 @@ impl Analyzer {
.map_err(|e| AlchemistError::Analyzer(format!("spawn_blocking failed: {}", e)))?
}
pub async fn probe_output_details(path: &Path) -> Result<OutputProbe> {
#[derive(Debug, Deserialize)]
struct ProbeData {
streams: Vec<ProbeStream>,
#[serde(default)]
format: ProbeFormat,
}
#[derive(Debug, Deserialize)]
struct ProbeStream {
codec_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ProbeFormat {
#[serde(default)]
tags: ProbeTags,
}
#[derive(Debug, Default, Deserialize)]
struct ProbeTags {
encoder: Option<String>,
}
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-print_format",
"json",
"-show_entries",
"stream=codec_name:format_tags=encoder",
])
.arg(&path)
.output()
.map_err(|e| AlchemistError::Analyzer(format!("Failed to run ffprobe: {}", e)))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(AlchemistError::Analyzer(format!("ffprobe failed: {}", err)));
}
let parsed: ProbeData = serde_json::from_slice(&output.stdout).map_err(|e| {
AlchemistError::Analyzer(format!("Failed to parse ffprobe JSON: {}", e))
})?;
let codec_name = parsed
.streams
.first()
.and_then(|s| s.codec_name.clone())
.unwrap_or_default();
Ok(OutputProbe {
codec_name,
encoder_tag: parsed.format.tags.encoder,
})
})
.await
.map_err(|e| AlchemistError::Analyzer(format!("spawn_blocking failed: {}", e)))?
}
// ... should_transcode adapted below ...
pub fn should_transcode(

View File

@@ -89,11 +89,15 @@ impl Executor for FfmpegExecutor {
})
.await?;
let (fallback_occurred, used_encoder) = self.verify_encoder(&output_path, encoder).await;
let (fallback_occurred, used_encoder, actual_codec, actual_encoder_name) =
self.verify_encoder(&output_path, encoder).await;
Ok(ExecutionResult {
requested_encoder: encoder,
used_encoder,
fallback_occurred,
actual_output_codec: actual_codec,
actual_encoder_name,
stats: ExecutionStats {
encode_time_secs: 0.0, // Pipeline calculates this
input_size: 0,
@@ -130,17 +134,41 @@ impl FfmpegExecutor {
}
}
async fn verify_encoder(&self, output_path: &Path, requested: Encoder) -> (bool, Encoder) {
async fn verify_encoder(
&self,
output_path: &Path,
requested: Encoder,
) -> (
bool,
Encoder,
Option<crate::config::OutputCodec>,
Option<String>,
) {
let expected_codec = encoder_codec_name(requested);
let actual_codec = crate::media::analyzer::Analyzer::probe_video_codec(output_path)
let probe = crate::media::analyzer::Analyzer::probe_output_details(output_path)
.await
.unwrap_or_default();
.ok();
let actual_codec_name = probe
.as_ref()
.map(|p| p.codec_name.as_str())
.unwrap_or_default()
.to_string();
let actual_codec = output_codec_from_name(&actual_codec_name);
let actual_encoder_name = probe.and_then(|p| p.encoder_tag);
let codec_matches =
actual_codec_name.is_empty() || actual_codec_name.eq_ignore_ascii_case(expected_codec);
let encoder_matches = actual_encoder_name
.as_deref()
.map(|name| encoder_tag_matches(requested, name))
.unwrap_or(true);
let fallback_occurred = !(codec_matches && encoder_matches);
if actual_codec.is_empty() || actual_codec.eq_ignore_ascii_case(expected_codec) {
(false, requested)
} else {
(true, requested)
}
(
fallback_occurred,
requested,
actual_codec,
actual_encoder_name,
)
}
}
@@ -167,3 +195,76 @@ fn encoder_codec_name(encoder: Encoder) -> &'static str {
| Encoder::H264X264 => "h264",
}
}
fn output_codec_from_name(codec: &str) -> Option<crate::config::OutputCodec> {
if codec.eq_ignore_ascii_case("av1") {
Some(crate::config::OutputCodec::Av1)
} else if codec.eq_ignore_ascii_case("hevc") || codec.eq_ignore_ascii_case("h265") {
Some(crate::config::OutputCodec::Hevc)
} else if codec.eq_ignore_ascii_case("h264") || codec.eq_ignore_ascii_case("avc") {
Some(crate::config::OutputCodec::H264)
} else {
None
}
}
fn encoder_tag_matches(requested: Encoder, encoder_tag: &str) -> bool {
let tag = encoder_tag.to_ascii_lowercase();
let expected_markers: &[&str] = match requested {
Encoder::Av1Qsv | Encoder::HevcQsv | Encoder::H264Qsv => &["qsv"],
Encoder::Av1Nvenc | Encoder::HevcNvenc | Encoder::H264Nvenc => &["nvenc"],
Encoder::Av1Vaapi | Encoder::HevcVaapi | Encoder::H264Vaapi => &["vaapi"],
Encoder::Av1Videotoolbox | Encoder::HevcVideotoolbox | Encoder::H264Videotoolbox => {
&["videotoolbox"]
}
Encoder::Av1Amf | Encoder::HevcAmf | Encoder::H264Amf => &["amf"],
Encoder::Av1Svt => &["svtav1", "svt-av1", "libsvtav1"],
Encoder::Av1Aom => &["libaom", "aom"],
Encoder::HevcX265 => &["x265", "libx265"],
Encoder::H264X264 => &["x264", "libx264"],
};
expected_markers.iter().any(|marker| tag.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_codec_mapping_handles_common_aliases() {
assert_eq!(
output_codec_from_name("av1"),
Some(crate::config::OutputCodec::Av1)
);
assert_eq!(
output_codec_from_name("hevc"),
Some(crate::config::OutputCodec::Hevc)
);
assert_eq!(
output_codec_from_name("h265"),
Some(crate::config::OutputCodec::Hevc)
);
assert_eq!(
output_codec_from_name("h264"),
Some(crate::config::OutputCodec::H264)
);
assert_eq!(
output_codec_from_name("avc"),
Some(crate::config::OutputCodec::H264)
);
assert_eq!(output_codec_from_name("vp9"), None);
}
#[test]
fn encoder_tag_matching_detects_mismatch() {
assert!(encoder_tag_matches(
Encoder::Av1Nvenc,
"Lavc61.3.100 av1_nvenc"
));
assert!(!encoder_tag_matches(
Encoder::Av1Nvenc,
"Lavc61.3.100 libsvtav1"
));
}
}

View File

@@ -4,7 +4,7 @@ use crate::media::analyzer::FfmpegAnalyzer;
use crate::media::executor::FfmpegExecutor;
use crate::media::planner::{build_hardware_capabilities, BasicPlanner};
use crate::orchestrator::Transcoder;
use crate::system::hardware::HardwareInfo;
use crate::system::hardware::HardwareState;
use crate::telemetry::{encoder_label, hardware_label, resolution_bucket, TelemetryEvent};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@@ -158,8 +158,11 @@ pub struct ExecutionPlan {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub requested_encoder: Encoder,
pub used_encoder: Encoder,
pub fallback_occurred: bool,
pub actual_output_codec: Option<crate::config::OutputCodec>,
pub actual_encoder_name: Option<String>,
pub stats: ExecutionStats,
}
@@ -201,7 +204,7 @@ pub struct Pipeline {
db: Arc<crate::db::Db>,
orchestrator: Arc<Transcoder>,
config: Arc<RwLock<crate::config::Config>>,
hw_info: Arc<Option<HardwareInfo>>,
hardware_state: HardwareState,
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
dry_run: bool,
}
@@ -211,7 +214,7 @@ impl Pipeline {
db: Arc<crate::db::Db>,
orchestrator: Arc<Transcoder>,
config: Arc<RwLock<crate::config::Config>>,
hw_info: Arc<Option<HardwareInfo>>,
hardware_state: HardwareState,
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
dry_run: bool,
) -> Self {
@@ -219,48 +222,98 @@ impl Pipeline {
db,
orchestrator,
config,
hw_info,
hardware_state,
tx,
dry_run,
}
}
pub async fn enqueue_discovered(&self, discovered: DiscoveredMedia) -> Result<()> {
enqueue_discovered_with_db(&self.db, discovered).await
let _ = enqueue_discovered_with_db(&self.db, discovered).await?;
Ok(())
}
}
pub async fn enqueue_discovered_with_db(
db: &crate::db::Db,
discovered: DiscoveredMedia,
) -> Result<()> {
) -> Result<bool> {
let settings = match db.get_file_settings().await {
Ok(settings) => settings,
Err(e) => {
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
crate::db::FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
}
default_file_settings()
}
};
if let Some(reason) = skip_reason_for_discovered_path(db, &discovered.path, &settings).await? {
tracing::info!("Skipping {:?} ({})", discovered.path, reason);
return Ok(false);
}
let output_path = settings.output_path_for(&discovered.path);
if output_path.exists() && !settings.should_replace_existing_output() {
tracing::info!(
"Skipping {:?} (output exists, replace_strategy = keep)",
discovered.path
);
return Ok(());
return Ok(false);
}
db.enqueue_job(&discovered.path, &output_path, discovered.mtime)
.await
}
fn default_file_settings() -> crate::db::FileSettings {
crate::db::FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
}
}
fn matches_generated_output_pattern(path: &Path, settings: &crate::db::FileSettings) -> bool {
let expected_extension = settings.output_extension.trim_start_matches('.');
if !expected_extension.is_empty() {
let actual_extension = match path.extension().and_then(|extension| extension.to_str()) {
Some(extension) => extension,
None => return false,
};
if !actual_extension.eq_ignore_ascii_case(expected_extension) {
return false;
}
}
let suffix = if settings.output_suffix.is_empty() {
"-alchemist"
} else {
settings.output_suffix.as_str()
};
path.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.ends_with(suffix))
}
async fn skip_reason_for_discovered_path(
db: &crate::db::Db,
path: &Path,
settings: &crate::db::FileSettings,
) -> Result<Option<&'static str>> {
if matches_generated_output_pattern(path, settings) {
return Ok(Some("matches generated output naming pattern"));
}
let path_string = path.to_string_lossy();
if db.has_job_with_output_path(path_string.as_ref()).await? {
return Ok(Some("already tracked as a job output"));
}
Ok(None)
}
impl Pipeline {
pub async fn process_job(&self, job: Job) -> std::result::Result<(), JobFailure> {
let file_path = PathBuf::from(&job.input_path);
@@ -269,13 +322,7 @@ impl Pipeline {
Ok(settings) => settings,
Err(e) => {
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
crate::db::FileSettings {
id: 1,
delete_source: false,
output_extension: "mkv".to_string(),
output_suffix: "-alchemist".to_string(),
replace_strategy: "keep".to_string(),
}
default_file_settings()
}
};
@@ -361,14 +408,14 @@ impl Pipeline {
tracing::info!("[Job {}] Codec: {}", job.id, metadata.codec_name);
let config_snapshot = self.config.read().await.clone();
let hw_info = self.hardware_state.snapshot().await;
let encoder_caps = Arc::new(crate::media::ffmpeg::encoder_caps_clone());
let planner = BasicPlanner::new(
Arc::new(config_snapshot.clone()),
self.hw_info.as_ref().clone(),
hw_info.clone(),
encoder_caps.clone(),
);
let hardware_caps =
build_hardware_capabilities(&encoder_caps, self.hw_info.as_ref().as_ref());
let hardware_caps = build_hardware_capabilities(&encoder_caps, hw_info.as_ref());
let mut plan = match planner
.plan(&analysis, &hardware_caps, &file_settings.output_extension)
.await
@@ -422,6 +469,7 @@ impl Pipeline {
self.emit_telemetry_event(TelemetryEventParams {
telemetry_enabled: config_snapshot.system.enable_telemetry,
output_codec: config_snapshot.transcode.output_codec,
encoder_override: None,
metadata,
event_type: "job_started",
status: None,
@@ -436,7 +484,7 @@ impl Pipeline {
let executor = FfmpegExecutor::new(
self.orchestrator.clone(),
Arc::new(config_snapshot.clone()),
self.hw_info.as_ref().clone(),
hw_info.clone(),
self.tx.clone(),
self.dry_run,
);
@@ -451,7 +499,7 @@ impl Pipeline {
return Err(JobFailure::EncoderUnavailable);
}
self.finalize_job(job, &file_path, &output_path, start_time, metadata)
self.finalize_job(job, &file_path, &output_path, start_time, metadata, &result)
.await
.map_err(|_| JobFailure::Transient)
}
@@ -476,6 +524,7 @@ impl Pipeline {
self.emit_telemetry_event(TelemetryEventParams {
telemetry_enabled: config_snapshot.system.enable_telemetry,
output_codec: config_snapshot.transcode.output_codec,
encoder_override: None,
metadata,
event_type: "job_finished",
status: Some("failure"),
@@ -526,6 +575,7 @@ impl Pipeline {
output_path: &Path,
start_time: std::time::Instant,
metadata: &MediaMetadata,
execution_result: &ExecutionResult,
) -> Result<()> {
let job_id = job.id;
let input_metadata = std::fs::metadata(input_path)?;
@@ -546,7 +596,6 @@ impl Pipeline {
let config = self.config.read().await;
let telemetry_enabled = config.system.enable_telemetry;
let output_codec = config.transcode.output_codec;
if output_size == 0 || reduction < config.transcode.size_reduction_threshold {
tracing::warn!(
@@ -662,7 +711,10 @@ impl Pipeline {
self.emit_telemetry_event(TelemetryEventParams {
telemetry_enabled,
output_codec,
output_codec: execution_result
.actual_output_codec
.unwrap_or(config.transcode.output_codec),
encoder_override: execution_result.actual_encoder_name.as_deref(),
metadata,
event_type: "job_finished",
status: Some("success"),
@@ -690,14 +742,20 @@ impl Pipeline {
return;
}
let hw = self.hw_info.as_ref().as_ref();
let hw_snapshot = self.hardware_state.snapshot().await;
let hw = hw_snapshot.as_ref();
let event = TelemetryEvent {
app_version: env!("CARGO_PKG_VERSION").to_string(),
event_type: params.event_type.to_string(),
status: params.status.map(str::to_string),
failure_reason: params.failure_reason.map(str::to_string),
hardware_model: hardware_label(hw),
encoder: Some(encoder_label(hw, params.output_codec)),
encoder: Some(
params
.encoder_override
.map(str::to_string)
.unwrap_or_else(|| encoder_label(hw, params.output_codec)),
),
video_codec: Some(params.output_codec.as_str().to_string()),
resolution: resolution_bucket(params.metadata.width, params.metadata.height),
duration_ms: params.duration_ms,
@@ -713,6 +771,7 @@ impl Pipeline {
struct TelemetryEventParams<'a> {
telemetry_enabled: bool,
output_codec: crate::config::OutputCodec,
encoder_override: Option<&'a str>,
metadata: &'a MediaMetadata,
event_type: &'a str,
status: Option<&'a str>,
@@ -731,3 +790,54 @@ fn map_failure(error: &crate::error::AlchemistError) -> JobFailure {
_ => JobFailure::Transient,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
#[test]
fn generated_output_pattern_matches_default_suffix() {
let settings = default_file_settings();
assert!(matches_generated_output_pattern(
Path::new("/media/movie-alchemist.mkv"),
&settings,
));
assert!(!matches_generated_output_pattern(
Path::new("/media/movie.mkv"),
&settings,
));
}
#[tokio::test]
async fn enqueue_discovered_rejects_known_output_paths(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut db_path = std::env::temp_dir();
db_path.push(format!(
"alchemist_output_filter_{}.db",
rand::random::<u64>()
));
let db = Db::new(db_path.to_string_lossy().as_ref()).await?;
db.update_file_settings(false, "mkv", "", "keep").await?;
let input = Path::new("/library/movie.mkv");
let output = Path::new("/library/movie-alchemist.mkv");
let _ = db
.enqueue_job(input, output, SystemTime::UNIX_EPOCH)
.await?;
let changed = enqueue_discovered_with_db(
&db,
DiscoveredMedia {
path: output.to_path_buf(),
mtime: SystemTime::UNIX_EPOCH,
},
)
.await?;
assert!(!changed);
drop(db);
let _ = std::fs::remove_file(db_path);
Ok(())
}
}

View File

@@ -3,7 +3,7 @@ use crate::db::{AlchemistEvent, Db};
use crate::error::Result;
use crate::media::pipeline::Pipeline;
use crate::media::scanner::Scanner;
use crate::system::hardware::HardwareInfo;
use crate::system::hardware::HardwareState;
use crate::Transcoder;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -15,7 +15,7 @@ pub struct Agent {
db: Arc<Db>,
orchestrator: Arc<Transcoder>,
config: Arc<RwLock<Config>>,
hw_info: Arc<Option<HardwareInfo>>,
hardware_state: HardwareState,
tx: Arc<broadcast::Sender<AlchemistEvent>>,
semaphore: Arc<Semaphore>,
semaphore_limit: Arc<AtomicUsize>,
@@ -30,7 +30,7 @@ impl Agent {
db: Arc<Db>,
orchestrator: Arc<Transcoder>,
config: Arc<RwLock<Config>>,
hw_info: Option<HardwareInfo>,
hardware_state: HardwareState,
tx: broadcast::Sender<AlchemistEvent>,
dry_run: bool,
) -> Self {
@@ -43,7 +43,7 @@ impl Agent {
db,
orchestrator,
config,
hw_info: Arc::new(hw_info),
hardware_state,
tx: Arc::new(tx),
semaphore: Arc::new(Semaphore::new(concurrent_jobs)),
semaphore_limit: Arc::new(AtomicUsize::new(concurrent_jobs)),
@@ -56,8 +56,12 @@ impl Agent {
pub async fn scan_and_enqueue(&self, directories: Vec<PathBuf>) -> Result<()> {
info!("Starting manual scan of directories: {:?}", directories);
let scanner = Scanner::new();
let files = scanner.scan(directories);
let files = tokio::task::spawn_blocking(move || {
let scanner = Scanner::new();
scanner.scan(directories)
})
.await
.map_err(|e| crate::error::AlchemistError::Unknown(format!("scan task failed: {}", e)))?;
let pipeline = self.pipeline();
@@ -220,7 +224,7 @@ impl Agent {
self.db.clone(),
self.orchestrator.clone(),
self.config.clone(),
self.hw_info.clone(),
self.hardware_state.clone(),
self.tx.clone(),
self.dry_run,
)

View File

@@ -1,6 +1,9 @@
use crate::db::{AlchemistEvent, Db, NotificationTarget};
use reqwest::Client;
use reqwest::{redirect::Policy, Client, Url};
use serde_json::json;
use std::net::IpAddr;
use std::time::Duration;
use tokio::net::lookup_host;
use tokio::sync::broadcast;
use tracing::{error, warn};
@@ -14,7 +17,11 @@ impl NotificationManager {
pub fn new(db: Db) -> Self {
Self {
db,
client: Client::new(),
client: Client::builder()
.timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@@ -96,6 +103,8 @@ impl NotificationManager {
event: &AlchemistEvent,
status: &str,
) -> Result<(), Box<dyn std::error::Error>> {
ensure_public_endpoint(&target.endpoint_url).await?;
match target.target_type.as_str() {
"discord" => self.send_discord(target, event, status).await,
"gotify" => self.send_gotify(target, event, status).await,
@@ -207,6 +216,63 @@ impl NotificationManager {
}
}
async fn ensure_public_endpoint(raw: &str) -> Result<(), Box<dyn std::error::Error>> {
let url = Url::parse(raw)?;
let host = match url.host_str() {
Some(value) => value,
None => return Err("notification endpoint host is missing".into()),
};
if host.eq_ignore_ascii_case("localhost") {
return Err("notification endpoint host is not allowed".into());
}
if let Ok(ip) = host.parse::<IpAddr>() {
if is_private_ip(ip) {
return Err("notification endpoint host is not allowed".into());
}
return Ok(());
}
let port = match url.port_or_known_default() {
Some(value) => value,
None => return Err("notification endpoint port is missing".into()),
};
let host_port = format!("{}:{}", host, port);
let mut resolved = false;
let addrs = tokio::time::timeout(Duration::from_secs(3), lookup_host(host_port)).await??;
for addr in addrs {
resolved = true;
if is_private_ip(addr.ip()) {
return Err("notification endpoint host is not allowed".into());
}
}
if !resolved {
return Err("notification endpoint host could not be resolved".into());
}
Ok(())
}
fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
}
}
}
#[cfg(test)]
mod tests {
use super::*;

39
src/runtime.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::env;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "config.toml";
const DEFAULT_DB_PATH: &str = "alchemist.db";
fn parse_bool_env(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
pub fn config_path() -> PathBuf {
env::var("ALCHEMIST_CONFIG_PATH")
.or_else(|_| env::var("ALCHEMIST_CONFIG"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_CONFIG_PATH))
}
pub fn db_path() -> PathBuf {
if let Ok(path) = env::var("ALCHEMIST_DB_PATH") {
return PathBuf::from(path);
}
if let Ok(data_dir) = env::var("ALCHEMIST_DATA_DIR") {
return Path::new(&data_dir).join(DEFAULT_DB_PATH);
}
PathBuf::from(DEFAULT_DB_PATH)
}
pub fn config_mutable() -> bool {
match env::var("ALCHEMIST_CONFIG_MUTABLE") {
Ok(value) => parse_bool_env(&value).unwrap_or(true),
Err(_) => true,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
use crate::error::Result;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use serde::{Deserialize, Serialize};
@@ -34,6 +36,27 @@ pub struct HardwareInfo {
pub supported_codecs: Vec<String>,
}
#[derive(Clone, Default)]
pub struct HardwareState {
inner: Arc<RwLock<Option<HardwareInfo>>>,
}
impl HardwareState {
pub fn new(initial: Option<HardwareInfo>) -> Self {
Self {
inner: Arc::new(RwLock::new(initial)),
}
}
pub async fn snapshot(&self) -> Option<HardwareInfo> {
self.inner.read().await.clone()
}
pub async fn replace(&self, next: Option<HardwareInfo>) {
*self.inner.write().await = next;
}
}
fn check_encoder_support(encoder: &str) -> bool {
let null_output = if cfg!(target_os = "windows") {
"NUL"
@@ -458,3 +481,44 @@ pub async fn detect_hardware_async_with_preference(
.await
.map_err(|e| crate::error::AlchemistError::Config(format!("spawn_blocking failed: {}", e)))?
}
pub async fn detect_hardware_for_config(config: &crate::config::Config) -> Result<HardwareInfo> {
let info = detect_hardware_async_with_preference(
config.hardware.allow_cpu_fallback,
config.hardware.preferred_vendor.clone(),
)
.await?;
if info.vendor == Vendor::Cpu && !config.hardware.allow_cpu_encoding {
return Err(crate::error::AlchemistError::Config(
"CPU encoding disabled".into(),
));
}
Ok(info)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn hardware_state_updates_snapshot() {
let state = HardwareState::new(Some(HardwareInfo {
vendor: Vendor::Nvidia,
device_path: None,
supported_codecs: vec!["av1".to_string()],
}));
assert_eq!(state.snapshot().await.unwrap().vendor, Vendor::Nvidia);
state
.replace(Some(HardwareInfo {
vendor: Vendor::Cpu,
device_path: None,
supported_codecs: vec!["av1".to_string(), "hevc".to_string()],
}))
.await;
assert_eq!(state.snapshot().await.unwrap().vendor, Vendor::Cpu);
}
}

View File

@@ -83,7 +83,6 @@ impl LibraryScanner {
.or_insert(watch_dir.is_recursive);
}
let scanner = Scanner::new();
let mut all_scanned = Vec::new();
for (path, recursive) in scan_targets {
@@ -97,7 +96,19 @@ impl LibraryScanner {
s.current_folder = Some(path.to_string_lossy().to_string());
}
let files = scanner.scan_with_recursion(vec![(path, recursive)]);
let scan_target = path.clone();
let files = match tokio::task::spawn_blocking(move || {
let scanner = Scanner::new();
scanner.scan_with_recursion(vec![(scan_target, recursive)])
})
.await
{
Ok(files) => files,
Err(e) => {
error!("Scan worker failed for {:?}: {}", path, e);
continue;
}
};
all_scanned.extend(files);
}
@@ -109,21 +120,15 @@ impl LibraryScanner {
let mut added = 0;
for file in all_scanned {
let path_str = file.path.to_string_lossy().to_string();
// Check if already exists
match db.get_job_by_input_path(&path_str).await {
Ok(Some(_)) => continue,
Ok(None) => {
if let Err(e) =
crate::media::pipeline::enqueue_discovered_with_db(&db, file).await
{
error!("Failed to add job during scan: {}", e);
} else {
match crate::media::pipeline::enqueue_discovered_with_db(&db, file).await {
Ok(changed) => {
if changed {
added += 1;
}
}
Err(e) => error!("Database error during scan check: {}", e),
Err(e) => {
error!("Failed to add job during scan: {}", e);
}
}
if added % 10 == 0 {
@@ -142,3 +147,72 @@ impl LibraryScanner {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
use std::time::Duration;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("{prefix}_{}.db", rand::random::<u64>()));
path
}
fn temp_watch_dir(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("{prefix}_{}", rand::random::<u64>()));
path
}
#[tokio::test]
async fn scanner_ignores_generated_outputs_during_full_scan() -> anyhow::Result<()> {
let db_path = temp_db_path("alchemist_scanner_test");
let watch_dir = temp_watch_dir("alchemist_scanner_dir");
std::fs::create_dir_all(&watch_dir)?;
let input_path = watch_dir.join("episode.mp4");
let generated_output = watch_dir.join("bonus-alchemist.mkv");
std::fs::write(&input_path, b"source")?;
std::fs::write(&generated_output, b"generated")?;
let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?);
let mut config = Config::default();
config.scanner.directories = vec![watch_dir.to_string_lossy().to_string()];
let config = Arc::new(RwLock::new(config));
let scanner = LibraryScanner::new(db.clone(), config);
scanner.start_scan().await?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
loop {
let status = scanner.get_status().await;
if !status.is_running {
break;
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!("scanner did not finish in time");
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let queued = db.get_jobs_by_status(crate::db::JobState::Queued).await?;
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].input_path, input_path.to_string_lossy());
assert!(db
.get_job_by_input_path(generated_output.to_string_lossy().as_ref())
.await?
.is_none());
cleanup_paths(&[watch_dir, db_path]);
Ok(())
}
fn cleanup_paths(paths: &[PathBuf]) {
for path in paths {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir_all(path);
}
}
}

View File

@@ -5,7 +5,10 @@
use crate::db::Db;
use crate::error::{AlchemistError, Result};
use crate::media::scanner::Scanner;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use notify::{
event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind, RenameMode},
Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
@@ -43,7 +46,9 @@ impl FileWatcher {
pending.insert(path);
}
_ = tokio::time::sleep(Duration::from_millis(debounce_ms)) => {
if !pending.is_empty() && last_process.elapsed().as_millis() >= debounce_ms as u128 {
if !pending.is_empty()
&& last_process.elapsed().as_millis() >= u128::from(debounce_ms)
{
for path in pending.drain() {
if path.exists() {
debug!("Auto-enqueuing new file: {:?}", path);
@@ -54,10 +59,10 @@ impl FileWatcher {
path: path.clone(),
mtime,
};
if let Err(e) = crate::media::pipeline::enqueue_discovered_with_db(&db_clone, discovered).await {
error!("Failed to auto-enqueue {:?}: {}", path, e);
} else {
info!("Auto-enqueued: {:?}", path);
match crate::media::pipeline::enqueue_discovered_with_db(&db_clone, discovered).await {
Ok(true) => info!("Auto-enqueued: {:?}", path),
Ok(false) => debug!("No queue update needed for {:?}", path),
Err(e) => error!("Failed to auto-enqueue {:?}: {}", path, e),
}
}
}
@@ -101,10 +106,13 @@ impl FileWatcher {
let tx_clone = self.tx.clone();
let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| {
if let Ok(event) = res {
move |res: std::result::Result<Event, notify::Error>| match res {
Ok(event) => {
if !should_enqueue_event(&event) {
return;
}
for path in event.paths {
// Check if it's a media file
if let Some(ext) = path.extension() {
if extensions.contains(&ext.to_string_lossy().to_lowercase()) {
let _ = tx_clone.send(path);
@@ -112,6 +120,7 @@ impl FileWatcher {
}
}
}
Err(err) => error!("Watcher event error: {}", err),
},
Config::default().with_poll_interval(Duration::from_secs(2)),
)
@@ -145,3 +154,116 @@ impl FileWatcher {
Ok(())
}
}
fn should_enqueue_event(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(CreateKind::File)
| EventKind::Modify(ModifyKind::Data(DataChange::Content | DataChange::Size))
| EventKind::Modify(ModifyKind::Name(RenameMode::To))
| EventKind::Access(AccessKind::Close(AccessMode::Any | AccessMode::Write))
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
use std::path::Path;
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("{prefix}_{}.db", rand::random::<u64>()));
path
}
fn temp_watch_dir(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("{prefix}_{}", rand::random::<u64>()));
path
}
async fn wait_for_queued_jobs(db: &Db, expected: i64) -> anyhow::Result<()> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
loop {
let stats = db.get_job_stats().await?;
if stats.queued == expected {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!("timed out waiting for queued jobs: expected {expected}");
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[test]
fn should_enqueue_only_stable_file_events() {
let create = Event {
kind: EventKind::Create(CreateKind::File),
paths: Vec::new(),
attrs: Default::default(),
};
assert!(should_enqueue_event(&create));
let rename_to = Event {
kind: EventKind::Modify(ModifyKind::Name(RenameMode::To)),
paths: Vec::new(),
attrs: Default::default(),
};
assert!(should_enqueue_event(&rename_to));
let broad_modify = Event {
kind: EventKind::Modify(ModifyKind::Any),
paths: Vec::new(),
attrs: Default::default(),
};
assert!(!should_enqueue_event(&broad_modify));
}
#[tokio::test]
async fn watcher_enqueues_real_media_but_ignores_generated_outputs() -> anyhow::Result<()> {
let db_path = temp_db_path("alchemist_watcher_smoke");
let watch_dir = temp_watch_dir("alchemist_watch_dir");
std::fs::create_dir_all(&watch_dir)?;
let db = Arc::new(Db::new(db_path.to_string_lossy().as_ref()).await?);
let watcher = FileWatcher::new(db.clone());
watcher.watch(&[WatchPath {
path: watch_dir.clone(),
recursive: false,
}])?;
let input_path = watch_dir.join("movie.mp4");
std::fs::write(&input_path, b"source")?;
wait_for_queued_jobs(db.as_ref(), 1).await?;
let generated_output = watch_dir.join("movie-alchemist.mkv");
std::fs::write(&generated_output, b"generated")?;
tokio::time::sleep(Duration::from_secs(2)).await;
let queued = db.get_jobs_by_status(crate::db::JobState::Queued).await?;
assert_eq!(queued.len(), 1);
assert_eq!(
std::fs::canonicalize(&queued[0].input_path)?,
std::fs::canonicalize(&input_path)?
);
assert!(db
.get_job_by_input_path(generated_output.to_string_lossy().as_ref())
.await?
.is_none());
assert!(Path::new(&queued[0].output_path).ends_with("movie-alchemist.mkv"));
watcher.watch(&[])?;
drop(db);
cleanup_paths(&[watch_dir, db_path]);
Ok(())
}
fn cleanup_paths(paths: &[PathBuf]) {
for path in paths {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir_all(path);
}
}
}

4
web-e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
playwright-report/
test-results/
.runtime/

21
web-e2e/bun.lock generated Normal file
View File

@@ -0,0 +1,21 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "alchemist-web-e2e",
"devDependencies": {
"@playwright/test": "^1.54.2",
},
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
}
}

81
web-e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,81 @@
import { request, type FullConfig } from "@playwright/test";
import fs from "node:fs/promises";
import {
AUTH_STATE_PATH,
MEDIA_DIR,
TEST_PASSWORD,
TEST_USERNAME,
} from "./testConfig";
interface SetupStatus {
setup_required: boolean;
}
async function waitForSetupStatus(maxMs = 30_000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < maxMs) {
try {
const api = await request.newContext({ baseURL: "http://127.0.0.1:3000" });
const response = await api.get("/api/setup/status");
await api.dispose();
if (response.ok()) {
return;
}
} catch {
// Continue polling until timeout.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("Timed out waiting for backend setup endpoint");
}
async function globalSetup(_config: FullConfig): Promise<void> {
await fs.mkdir(MEDIA_DIR, { recursive: true });
await waitForSetupStatus();
const api = await request.newContext({ baseURL: "http://127.0.0.1:3000" });
const statusResponse = await api.get("/api/setup/status");
if (!statusResponse.ok()) {
throw new Error(`Setup status request failed: ${statusResponse.status()} ${await statusResponse.text()}`);
}
const setupStatus = (await statusResponse.json()) as SetupStatus;
if (setupStatus.setup_required) {
const setupPayload = {
username: TEST_USERNAME,
password: TEST_PASSWORD,
size_reduction_threshold: 0.3,
min_bpp_threshold: 0.1,
min_file_size_mb: 100,
concurrent_jobs: 2,
output_codec: "av1",
quality_profile: "balanced",
directories: [MEDIA_DIR],
allow_cpu_encoding: true,
enable_telemetry: false,
};
const setupResponse = await api.post("/api/setup/complete", { data: setupPayload });
if (!setupResponse.ok()) {
throw new Error(`Setup completion failed: ${setupResponse.status()} ${await setupResponse.text()}`);
}
}
const loginResponse = await api.post("/api/auth/login", {
data: {
username: TEST_USERNAME,
password: TEST_PASSWORD,
},
});
if (!loginResponse.ok()) {
throw new Error(`Login failed: ${loginResponse.status()} ${await loginResponse.text()}`);
}
await api.storageState({ path: AUTH_STATE_PATH });
await api.dispose();
}
export default globalSetup;

16
web-e2e/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "alchemist-web-e2e",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1",
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"test:reliability": "playwright test tests/settings-nonok.spec.ts tests/setup-recovery.spec.ts tests/stats-poller.spec.ts tests/jobs-actions-nonok.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.54.2"
}
}

View File

@@ -0,0 +1,50 @@
import { defineConfig } from "@playwright/test";
import { CONFIG_PATH, DB_PATH } from "./testConfig";
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: "list",
globalSetup: "./global-setup.ts",
use: {
baseURL: "http://127.0.0.1:3000",
headless: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "auth",
testIgnore: /setup-recovery\.spec\.ts/,
use: {
storageState: ".runtime/auth-state.json",
},
},
{
name: "setup",
testMatch: /setup-recovery\.spec\.ts/,
use: {
storageState: undefined,
},
},
],
webServer: {
command: "sh -c 'mkdir -p .runtime/media && cargo run --manifest-path ../Cargo.toml -- --reset-auth'",
url: "http://127.0.0.1:3000/api/health",
reuseExistingServer: false,
timeout: 120_000,
env: {
ALCHEMIST_CONFIG_PATH: CONFIG_PATH,
ALCHEMIST_DB_PATH: DB_PATH,
ALCHEMIST_CONFIG_MUTABLE: "true",
RUST_LOG: "warn",
},
},
});

10
web-e2e/testConfig.ts Normal file
View File

@@ -0,0 +1,10 @@
import path from "node:path";
export const RUNTIME_DIR = path.resolve(process.cwd(), ".runtime");
export const MEDIA_DIR = path.join(RUNTIME_DIR, "media");
export const AUTH_STATE_PATH = path.join(RUNTIME_DIR, "auth-state.json");
export const CONFIG_PATH = path.join(RUNTIME_DIR, "config.toml");
export const DB_PATH = path.join(RUNTIME_DIR, "alchemist.db");
export const TEST_USERNAME = "playwright";
export const TEST_PASSWORD = "playwright-password";

19
web-e2e/tests/helpers.ts Normal file
View File

@@ -0,0 +1,19 @@
import { expect, type Page, type Route } from "@playwright/test";
export async function fulfillJson(route: Route, status: number, body: unknown): Promise<void> {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
export async function mockEngineStatus(page: Page): Promise<void> {
await page.route("**/api/engine/status", async (route) => {
await fulfillJson(route, 200, { status: "ok" });
});
}
export async function expectVisibleError(page: Page, message: string): Promise<void> {
await expect(page.getByText(message).first()).toBeVisible();
}

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
import { expectVisibleError, fulfillJson, mockEngineStatus } from "./helpers";
const jobsTable = [
{
id: 1,
input_path: "/media/sample.mkv",
output_path: "/output/sample-av1.mkv",
status: "completed",
priority: 0,
progress: 100,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
vmaf_score: 95,
decision_reason: "Good candidate",
},
];
test.beforeEach(async ({ page }) => {
await mockEngineStatus(page);
await page.route("**/api/jobs/table**", async (route) => {
await fulfillJson(route, 200, jobsTable);
});
});
test("jobs batch delete failure is surfaced to the user", async ({ page }) => {
await page.route("**/api/jobs/batch", async (route) => {
await fulfillJson(route, 500, { message: "forced batch failure" });
});
await page.goto("/jobs");
await expect(page.getByTitle("/media/sample.mkv")).toBeVisible();
await page.locator("tbody input[type='checkbox']").first().check();
await page.getByTitle("Delete").first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Delete" }).click();
await expectVisibleError(page, "forced batch failure");
});
test("clear completed failure is surfaced to the user", async ({ page }) => {
await page.route("**/api/jobs/clear-completed", async (route) => {
await fulfillJson(route, 500, { message: "forced clear-completed failure" });
});
await page.goto("/jobs");
await expect(page.getByTitle("/media/sample.mkv")).toBeVisible();
await page.getByRole("button", { name: /Clear Completed/i }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Clear" }).click();
await expectVisibleError(page, "forced clear-completed failure");
});
test("single job delete failure is surfaced to the user", async ({ page }) => {
await page.route("**/api/jobs/1/delete", async (route) => {
await fulfillJson(route, 500, { message: "forced single-job failure" });
});
await page.goto("/jobs");
await expect(page.getByTitle("/media/sample.mkv")).toBeVisible();
await page.getByTitle("Actions").first().click();
await page.getByRole("button", { name: /^Delete$/ }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Delete" }).click();
await expectVisibleError(page, "forced single-job failure");
});
test("log clear failure is surfaced to the user", async ({ page }) => {
await page.route("**/api/logs/history**", async (route) => {
await fulfillJson(route, 200, [
{
id: 1,
level: "info",
message: "hello",
created_at: "2025-01-01T00:00:00Z",
},
]);
});
await page.route("**/api/logs", async (route) => {
if (route.request().method() === "DELETE") {
await fulfillJson(route, 500, { message: "forced log clear failure" });
return;
}
await route.continue();
});
await page.goto("/logs");
await expect(page.getByText("Server Logs")).toBeVisible();
await page.getByTitle("Clear Server Logs").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Clear Logs" }).click();
await expectVisibleError(page, "forced log clear failure");
});

View File

@@ -0,0 +1,246 @@
import { expect, test } from "@playwright/test";
import { expectVisibleError, fulfillJson, mockEngineStatus } from "./helpers";
const transcodeSettings = {
concurrent_jobs: 2,
size_reduction_threshold: 0.3,
min_bpp_threshold: 0.1,
min_file_size_mb: 100,
output_codec: "av1",
quality_profile: "balanced",
threads: 0,
allow_fallback: true,
hdr_mode: "preserve",
tonemap_algorithm: "hable",
tonemap_peak: 100,
tonemap_desat: 0.2,
};
test.beforeEach(async ({ page }) => {
await mockEngineStatus(page);
});
test("appearance preference save failure is visible", async ({ page }) => {
await page.route("**/api/ui/preferences", async (route) => {
await fulfillJson(route, 500, { message: "forced appearance failure" });
});
await page.goto("/settings?tab=appearance");
await page.getByRole("button", { name: /Sunset/i }).first().click();
await expectVisibleError(page, "Unable to save theme preference to server.");
});
test("file settings save failure is visible", async ({ page }) => {
await page.route("**/api/settings/files", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, {
delete_source: false,
output_extension: "mkv",
output_suffix: "-alchemist",
replace_strategy: "keep",
});
return;
}
await fulfillJson(route, 500, { message: "forced files failure" });
});
await page.goto("/settings?tab=files");
await page.getByRole("button", { name: "Save Settings" }).click();
await expectVisibleError(page, "forced files failure");
await expect(page.getByText("File settings saved.")).toHaveCount(0);
});
test("schedule add failure is visible", async ({ page }) => {
await page.route("**/api/settings/schedule", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, []);
return;
}
await fulfillJson(route, 500, { message: "forced schedule failure" });
});
await page.goto("/settings?tab=schedule");
await page.getByRole("button", { name: "Add Schedule" }).click();
await page.getByRole("button", { name: "Save Schedule" }).click();
await expectVisibleError(page, "forced schedule failure");
});
test("schedule delete failure is visible", async ({ page }) => {
await page.route("**/api/settings/schedule", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, [
{
id: 7,
start_time: "00:00",
end_time: "08:00",
days_of_week: "[1,2,3,4,5]",
enabled: true,
},
]);
return;
}
await route.continue();
});
await page.route("**/api/settings/schedule/7", async (route) => {
await fulfillJson(route, 500, { message: "forced schedule delete failure" });
});
await page.goto("/settings?tab=schedule");
await page.getByLabel("Delete schedule 00:00-08:00").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Remove" }).click();
await expectVisibleError(page, "forced schedule delete failure");
});
test("notification add failure is visible", async ({ page }) => {
await page.route("**/api/settings/notifications", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, []);
return;
}
await fulfillJson(route, 500, { message: "forced notifications add failure" });
});
await page.goto("/settings?tab=notifications");
await page.getByRole("button", { name: "Add Target" }).click();
await page.getByPlaceholder("My Discord").fill("Playwright Target");
await page.getByPlaceholder("https://discord.com/api/webhooks/...").fill("https://example.invalid/webhook");
await page.getByRole("button", { name: "Save Target" }).click();
await expectVisibleError(page, "forced notifications add failure");
});
test("notification test send failure is visible", async ({ page }) => {
await page.route("**/api/settings/notifications", async (route) => {
await fulfillJson(route, 200, [
{
id: 10,
name: "Discord",
target_type: "discord",
endpoint_url: "https://example.invalid/webhook",
auth_token: null,
events: "[\"completed\",\"failed\"]",
enabled: true,
},
]);
});
await page.route("**/api/settings/notifications/test", async (route) => {
await fulfillJson(route, 500, { message: "forced notifications test failure" });
});
await page.goto("/settings?tab=notifications");
await page.getByTitle("Test Notification").click();
await expectVisibleError(page, "forced notifications test failure");
});
test("watch folder add failure is visible", async ({ page }) => {
await page.route("**/api/settings/watch-dirs", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, []);
return;
}
await fulfillJson(route, 500, { message: "forced watch add failure" });
});
await page.goto("/settings?tab=watch");
await page.getByPlaceholder("Enter full directory path...").fill("/tmp/test-media");
await page.getByRole("button", { name: /^Add$/ }).click();
await expectVisibleError(page, "forced watch add failure");
});
test("watch folder remove failure is visible", async ({ page }) => {
await page.route("**/api/settings/watch-dirs", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, [
{ id: 5, path: "/tmp/test-media", is_recursive: true },
]);
return;
}
await route.continue();
});
await page.route("**/api/settings/watch-dirs/5", async (route) => {
await fulfillJson(route, 500, { message: "forced watch delete failure" });
});
await page.goto("/settings?tab=watch");
await page.getByTitle("Stop watching").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Stop Watching" }).click();
await expectVisibleError(page, "forced watch delete failure");
});
test("transcode settings save failure is visible", async ({ page }) => {
await page.route("**/api/settings/transcode", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, transcodeSettings);
return;
}
await fulfillJson(route, 500, { message: "forced transcode failure" });
});
await page.goto("/settings?tab=transcode");
await page.getByRole("button", { name: "Save Settings" }).click();
await expectVisibleError(page, "forced transcode failure");
});
test("hardware update failure is visible", async ({ page }) => {
await page.route("**/api/system/hardware", async (route) => {
await fulfillJson(route, 200, {
vendor: "Cpu",
device_path: null,
supported_codecs: ["h264", "hevc"],
});
});
await page.route("**/api/settings/hardware", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, {
allow_cpu_fallback: true,
allow_cpu_encoding: true,
cpu_preset: "medium",
preferred_vendor: null,
});
return;
}
await fulfillJson(route, 500, { message: "forced hardware failure" });
});
await page.goto("/settings?tab=hardware");
await page.locator("button.relative.inline-flex").first().click();
await expectVisibleError(page, "forced hardware failure");
});
test("system settings save failure is visible", async ({ page }) => {
await page.route("**/api/settings/system", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, 200, {
monitoring_poll_interval: 2,
enable_telemetry: true,
});
return;
}
await fulfillJson(route, 500, { message: "forced system failure" });
});
await page.goto("/settings?tab=system");
await page.getByRole("button", { name: "Save Settings" }).click();
await expectVisibleError(page, "forced system failure");
await expect(page.getByText("Settings saved successfully.")).toHaveCount(0);
});

View File

@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { fulfillJson } from "./helpers";
test("setup step 5 shows retry and back recovery on scan failures", async ({ page }) => {
let scanStartAttempts = 0;
await page.route("**/api/setup/status", async (route) => {
await fulfillJson(route, 200, {
setup_required: true,
enable_telemetry: false,
});
});
await page.route("**/api/system/hardware", async (route) => {
await fulfillJson(route, 200, {
vendor: "Cpu",
device_path: null,
supported_codecs: ["h264", "hevc", "av1"],
});
});
await page.route("**/api/setup/complete", async (route) => {
await fulfillJson(route, 200, { status: "ok" });
});
await page.route("**/api/scan/start", async (route) => {
scanStartAttempts += 1;
if (scanStartAttempts < 3) {
await fulfillJson(route, 500, { message: "forced scan start failure" });
return;
}
await route.fulfill({ status: 202, body: "" });
});
await page.route("**/api/scan/status", async (route) => {
await fulfillJson(route, 200, {
is_running: false,
files_found: 1,
files_added: 1,
current_folder: null,
});
});
await page.goto("/setup");
await expect(page.getByRole("heading", { name: "Alchemist Setup" })).toBeVisible();
await page.getByPlaceholder("admin").fill("playwright");
await page.getByPlaceholder("••••••••").fill("playwright-password");
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByRole("heading", { name: "Final Review" })).toBeVisible();
await page.getByRole("button", { name: "Build Engine" }).click();
await expect(page.getByText("Scan failed or became unavailable.")).toBeVisible();
await expect(page.getByText("forced scan start failure")).toBeVisible();
await page.getByRole("button", { name: "Back to Review" }).click();
await expect(page.getByRole("heading", { name: "Final Review" })).toBeVisible();
await page.getByRole("button", { name: "Build Engine" }).click();
await expect(page.getByText("Scan failed or became unavailable.")).toBeVisible();
await page.getByRole("button", { name: "Retry Scan" }).click();
await expect(page.getByRole("button", { name: "Enter Dashboard" })).toBeVisible();
await expect(scanStartAttempts).toBe(3);
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
import { fulfillJson, mockEngineStatus } from "./helpers";
test("dashboard uses a single stats poller and handles visibility changes", async ({ page, context }) => {
const statsCallTimes: number[] = [];
await mockEngineStatus(page);
await page.route("**/api/stats", async (route) => {
statsCallTimes.push(Date.now());
await fulfillJson(route, 200, {
active: 1,
concurrent_limit: 2,
completed: 10,
failed: 0,
total: 11,
});
});
await page.route("**/api/jobs/table**", async (route) => {
await fulfillJson(route, 200, []);
});
await page.route("**/api/settings/system", async (route) => {
await fulfillJson(route, 200, {
monitoring_poll_interval: 2,
enable_telemetry: false,
});
});
await page.route("**/api/system/resources", async (route) => {
await fulfillJson(route, 200, {
cpu_percent: 12,
memory_used_mb: 2048,
memory_total_mb: 8192,
memory_percent: 25,
uptime_seconds: 3600,
active_jobs: 1,
concurrent_limit: 2,
cpu_count: 8,
gpu_utilization: 0,
gpu_memory_percent: 0,
});
});
await page.goto("/");
await expect
.poll(() => statsCallTimes.length, { timeout: 10_000 })
.toBeGreaterThanOrEqual(1);
await page.waitForTimeout(6_200);
expect(statsCallTimes.length).toBeGreaterThanOrEqual(2);
expect(statsCallTimes.length).toBeLessThanOrEqual(3);
const helperPage = await context.newPage();
await helperPage.goto("about:blank");
await helperPage.bringToFront();
const beforeHidden = statsCallTimes.length;
await helperPage.waitForTimeout(7_000);
const hiddenDelta = statsCallTimes.length - beforeHidden;
expect(hiddenDelta).toBeLessThanOrEqual(1);
await page.bringToFront();
const beforeRefocus = statsCallTimes.length;
await page.waitForTimeout(6_200);
expect(statsCallTimes.length).toBeGreaterThan(beforeRefocus);
await helperPage.close();
});

178
web/bun.lock generated
View File

@@ -16,9 +16,11 @@
"tailwind-merge": "^2.0.0",
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.9.3",
},
},
},
@@ -30,10 +32,14 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="],
"@astrojs/language-server": ["@astrojs/language-server@2.16.3", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/yaml2ts": "^0.2.2", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.27", "@volar/language-core": "~2.4.27", "@volar/language-server": "~2.4.27", "@volar/language-service": "~2.4.27", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.68", "volar-service-emmet": "0.0.68", "volar-service-html": "0.0.68", "volar-service-prettier": "0.0.68", "volar-service-typescript": "0.0.68", "volar-service-typescript-twoslash-queries": "0.0.68", "volar-service-yaml": "0.0.68", "vscode-html-languageservice": "^5.6.1", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA=="],
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.10", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.5", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.19.0", "smol-toml": "^1.5.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A=="],
"@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@@ -44,6 +50,8 @@
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="],
"@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.2", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@@ -84,6 +92,20 @@
"@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="],
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
"@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="],
"@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="],
"@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="],
"@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="],
"@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="],
@@ -310,11 +332,31 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="],
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
"@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="],
"@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="],
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
"@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -368,14 +410,20 @@
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
@@ -438,7 +486,9 @@
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -456,8 +506,12 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -480,6 +534,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
@@ -548,9 +604,13 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
@@ -580,6 +640,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -686,6 +748,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -728,6 +792,8 @@
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
@@ -754,6 +820,8 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -772,7 +840,7 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -798,6 +866,12 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
@@ -832,11 +906,11 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
@@ -872,8 +946,12 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
@@ -918,6 +996,40 @@
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"volar-service-css": ["volar-service-css@0.0.68", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q=="],
"volar-service-emmet": ["volar-service-emmet@0.0.68", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ=="],
"volar-service-html": ["volar-service-html@0.0.68", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA=="],
"volar-service-prettier": ["volar-service-prettier@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw=="],
"volar-service-typescript": ["volar-service-typescript@0.0.68", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw=="],
"volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w=="],
"volar-service-yaml": ["volar-service-yaml@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.19.2" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA=="],
"vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="],
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
"vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
@@ -928,10 +1040,16 @@
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
@@ -956,11 +1074,11 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
@@ -972,20 +1090,40 @@
"ofetch/ufo": ["ufo@1.6.2", "", {}, "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
"yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="],
"boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"boxen/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -1040,6 +1178,18 @@
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"widest-line/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
}
}

4632
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
{
"name": "alchemist-web",
"version": "0.2.8",
"version": "0.2.9",
"private": true,
"packageManager": "bun@1",
"type": "module",
"scripts": {
"dev": "astro dev",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "astro check",
"build": "astro build",
"preview": "astro preview"
"preview": "astro preview",
"verify": "bun run typecheck && bun run check && bun run build"
},
"dependencies": {
"@astrojs/react": "4.4.2",
@@ -20,6 +24,8 @@
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"typescript": "^5.9.3",
"tailwindcss": "^3.3.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Terminal, Server, Cpu, Activity, ShieldCheck } from "lucide-react";
import { apiFetch } from "../lib/api";
import { X, Terminal, Server, Cpu, Activity, ShieldCheck, type LucideIcon } from "lucide-react";
import { apiJson } from "../lib/api";
interface SystemInfo {
version: string;
@@ -16,17 +16,99 @@ interface AboutDialogProps {
onClose: () => void;
}
function focusableElements(root: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(element) => !element.hasAttribute("disabled")
);
}
export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const [info, setInfo] = useState<SystemInfo | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const lastFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen && !info) {
apiFetch("/api/system/info")
.then(res => res.json())
apiJson<SystemInfo>("/api/system/info")
.then(setInfo)
.catch(console.error);
}
}, [isOpen]);
}, [isOpen, info]);
useEffect(() => {
if (!isOpen) {
return;
}
lastFocusedRef.current = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (dialog) {
const focusables = focusableElements(dialog);
if (focusables.length > 0) {
focusables[0].focus();
} else {
dialog.focus();
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") {
return;
}
const root = dialogRef.current;
if (!root) {
return;
}
const focusables = focusableElements(root);
if (focusables.length === 0) {
event.preventDefault();
root.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = document.activeElement as HTMLElement | null;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
if (lastFocusedRef.current) {
lastFocusedRef.current.focus();
}
};
}, [isOpen, onClose]);
return (
<AnimatePresence>
@@ -44,6 +126,11 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
onClick={e => e.stopPropagation()}
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="about-dialog-title"
tabIndex={-1}
className="w-full max-w-md bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
>
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-helios-solar/10 to-transparent pointer-events-none" />
@@ -64,7 +151,7 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
</div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-helios-ink tracking-tight">Alchemist</h2>
<h2 id="about-dialog-title" className="text-2xl font-bold text-helios-ink tracking-tight">Alchemist</h2>
<p className="text-helios-slate font-medium">Media Transcoding Agent</p>
</div>
@@ -97,7 +184,13 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
);
}
function InfoRow({ icon: Icon, label, value }: { icon: any, label: string, value: string }) {
interface InfoRowProps {
icon: LucideIcon;
label: string;
value: string;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<div className="flex items-center justify-between p-3 rounded-xl bg-helios-surface-soft border border-helios-line/10">
<div className="flex items-center gap-3">

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { apiFetch } from "../lib/api";
import { apiAction, isApiError } from "../lib/api";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -284,14 +284,12 @@ const applyRootTheme = (themeId: string) => {
};
export default function AppearanceSettings() {
// Initialize from local storage or default
const [activeThemeId, setActiveThemeId] = useState(
() => (typeof window !== 'undefined' ? localStorage.getItem("theme") : null) || getRootTheme() || "helios-orange"
);
const [savingThemeId, setSavingThemeId] = useState<string | null>(null);
const [error, setError] = useState("");
// Effect to ensure theme is applied on mount (if mismatched)
useEffect(() => {
applyRootTheme(activeThemeId);
}, [activeThemeId]);
@@ -302,33 +300,21 @@ export default function AppearanceSettings() {
return;
}
const _previousTheme = activeThemeId;
setActiveThemeId(themeId);
setSavingThemeId(themeId);
setError("");
applyRootTheme(themeId);
try {
// Determine API endpoint.
// Since we don't have the full Helios API, we'll implement a simple one or just use local storage for now if backend isn't ready.
// But the plan says "Implement PUT /api/ui/preferences".
// We'll try to fetch it.
const response = await apiFetch("/api/ui/preferences", {
await apiAction("/api/ui/preferences", {
method: "POST",
body: JSON.stringify({ active_theme_id: themeId }),
});
if (!response.ok) {
// If backend doesn't support it yet, we just rely on LocalStorage, which we already set in applyRootTheme.
// So we might warn but not revert UI, or just suppress error if 404.
if (response.status !== 404) {
throw new Error("Failed to save preference");
}
}
} catch (saveError) {
console.warn("Theme save failed, using local storage fallback", saveError);
// We don't revert here because we want the UI to update immediately and persist locally at least.
// setError("Unable to save theme preference to server.");
if (isApiError(saveError) && saveError.status === 404) {
return;
}
setError("Unable to save theme preference to server.");
} finally {
setSavingThemeId(null);
}

View File

@@ -0,0 +1,50 @@
import { useEffect } from "react";
import { apiFetch, apiJson } from "../lib/api";
interface SetupStatus {
setup_required?: boolean;
}
export default function AuthGuard() {
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
const path = window.location.pathname;
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
if (isAuthPage) {
return;
}
try {
const engineStatus = await apiFetch("/api/engine/status");
if (engineStatus.status !== 401 || cancelled) {
return;
}
const setupStatus = await apiJson<SetupStatus>("/api/setup/status");
if (cancelled) {
return;
}
window.location.href = setupStatus.setup_required ? "/setup" : "/login";
} catch {
// Keep user on current page on transient backend/network failures.
}
};
const handleAfterSwap = () => {
void checkAuth();
};
void checkAuth();
document.addEventListener("astro:after-swap", handleAfterSwap);
return () => {
cancelled = true;
document.removeEventListener("astro:after-swap", handleAfterSwap);
};
}, []);
return null;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Activity,
CheckCircle2,
@@ -6,16 +6,13 @@ import {
HardDrive,
Database,
Zap,
Terminal
Terminal,
type LucideIcon,
} from "lucide-react";
import { apiFetch } from "../lib/api";
interface Stats {
total: number;
completed: number;
active: number;
failed: number;
}
import { apiJson, isApiError } from "../lib/api";
import { useSharedStats } from "../lib/statsStore";
import { showToast } from "../lib/toast";
import ResourceMonitor from "./ResourceMonitor";
interface Job {
id: number;
@@ -24,43 +21,94 @@ interface Job {
created_at: string;
}
import ResourceMonitor from "./ResourceMonitor";
interface StatCardProps {
label: string;
value: number;
icon: LucideIcon;
colorClass: string;
}
interface QuickStartItem {
title: string;
body: ReactNode;
icon: LucideIcon;
tone: string;
bg: string;
}
const DEFAULT_STATS = {
total: 0,
completed: 0,
active: 0,
failed: 0,
concurrent_limit: 1,
};
function StatCard({ label, value, icon: Icon, colorClass }: StatCardProps) {
return (
<div className="p-5 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm relative overflow-hidden group hover:bg-helios-surface-soft transition-colors">
<div className={`absolute -top-2 -right-2 p-3 opacity-10 group-hover:opacity-20 transition-opacity ${colorClass}`}>
<Icon size={64} />
</div>
<div className="relative z-10 flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">{label}</span>
<span className={`text-3xl font-bold font-mono tracking-tight ${colorClass}`}>{value}</span>
</div>
</div>
);
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats>({ total: 0, completed: 0, active: 0, failed: 0 });
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const _lastJob = jobs[0];
const [jobsLoading, setJobsLoading] = useState(true);
const { stats: sharedStats, error: statsError } = useSharedStats();
const stats = sharedStats ?? DEFAULT_STATS;
useEffect(() => {
const fetchData = async () => {
if (!statsError) {
return;
}
showToast({
kind: "error",
title: "Stats",
message: statsError,
});
}, [statsError]);
useEffect(() => {
const fetchJobs = async () => {
try {
const [statsRes, jobsRes] = await Promise.all([
apiFetch("/api/stats"),
apiFetch(`/api/jobs/table?${new URLSearchParams({
const data = await apiJson<Job[]>(
`/api/jobs/table?${new URLSearchParams({
limit: "5",
sort: "created_at",
sort_desc: "true",
})}`)
]);
if (statsRes.ok) {
setStats(await statsRes.json());
}
if (jobsRes.ok) {
setJobs(await jobsRes.json());
}
} catch (e) {
console.error("Dashboard fetch error", e);
})}`
);
setJobs(data);
} catch (error) {
const message = isApiError(error) ? error.message : "Failed to fetch jobs";
showToast({ kind: "error", title: "Dashboard", message });
} finally {
setLoading(false);
setJobsLoading(false);
}
};
void fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
void fetchJobs();
const pollVisible = () => {
if (document.visibilityState === "visible") {
void fetchJobs();
}
};
const intervalId = window.setInterval(pollVisible, 5000);
document.addEventListener("visibilitychange", pollVisible);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", pollVisible);
};
}, []);
const formatRelativeTime = (iso?: string) => {
@@ -77,8 +125,9 @@ export default function Dashboard() {
return `${days}d ago`;
};
const quickStartItems = useMemo(() => {
const items = [];
const quickStartItems = useMemo<QuickStartItem[]>(() => {
const items: QuickStartItem[] = [];
if (stats.total === 0) {
items.push({
title: "Connect Your Library",
@@ -100,6 +149,7 @@ export default function Dashboard() {
bg: "bg-helios-solar/10",
});
}
if (stats.failed > 0) {
items.push({
title: "Review Failures",
@@ -117,6 +167,7 @@ export default function Dashboard() {
bg: "bg-red-500/10",
});
}
if (stats.active === 0 && stats.total > 0) {
items.push({
title: "Queue New Work",
@@ -134,6 +185,7 @@ export default function Dashboard() {
bg: "bg-emerald-500/10",
});
}
if (items.length === 0) {
items.push({
title: "Optimize Throughput",
@@ -151,53 +203,20 @@ export default function Dashboard() {
bg: "bg-amber-500/10",
});
}
return items.slice(0, 3);
}, [stats.active, stats.failed, stats.total]);
const StatCard = ({ label, value, icon: Icon, colorClass }: any) => (
<div className="p-5 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm relative overflow-hidden group hover:bg-helios-surface-soft transition-colors">
<div className={`absolute -top-2 -right-2 p-3 opacity-10 group-hover:opacity-20 transition-opacity ${colorClass}`}>
<Icon size={64} />
</div>
<div className="relative z-10 flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">{label}</span>
<span className={`text-3xl font-bold font-mono tracking-tight ${colorClass.replace("text-", "text-")}`}>{value}</span>
</div>
</div>
);
return (
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-hidden">
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
label="Active Jobs"
value={stats.active}
icon={Zap}
colorClass="text-amber-500"
/>
<StatCard
label="Completed"
value={stats.completed}
icon={CheckCircle2}
colorClass="text-emerald-500"
/>
<StatCard
label="Failed"
value={stats.failed}
icon={AlertCircle}
colorClass="text-red-500"
/>
<StatCard
label="Total Processed"
value={stats.total}
icon={Database}
colorClass="text-helios-solar"
/>
<StatCard label="Active Jobs" value={stats.active} icon={Zap} colorClass="text-amber-500" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} colorClass="text-emerald-500" />
<StatCard label="Failed" value={stats.failed} icon={AlertCircle} colorClass="text-red-500" />
<StatCard label="Total Processed" value={stats.total} icon={Database} colorClass="text-helios-solar" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Recent Activity */}
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm flex flex-col">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
@@ -208,7 +227,7 @@ export default function Dashboard() {
</div>
<div className="flex flex-col gap-3">
{loading && jobs.length === 0 ? (
{jobsLoading && jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate animate-pulse">Loading activity...</div>
) : jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate/60 italic">No recent activity found.</div>
@@ -218,10 +237,13 @@ export default function Dashboard() {
return (
<div key={job.id} className="flex items-center justify-between p-3 rounded-xl bg-helios-surface-soft hover:bg-white/5 transition-colors border border-transparent hover:border-helios-line/20">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${status === 'completed' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]' :
status === 'failed' ? 'bg-red-500' :
status === 'encoding' || status === 'analyzing' ? 'bg-amber-500 animate-pulse' :
'bg-helios-slate'
<div className={`w-2 h-2 rounded-full shrink-0 ${status === "completed"
? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]"
: status === "failed"
? "bg-red-500"
: status === "encoding" || status === "analyzing"
? "bg-amber-500 animate-pulse"
: "bg-helios-slate"
}`} />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-helios-ink truncate" title={job.input_path}>
@@ -242,7 +264,6 @@ export default function Dashboard() {
</div>
</div>
{/* Getting Started Tips */}
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm h-full">
<h3 className="text-lg font-bold text-helios-ink mb-6 flex items-center gap-2">
<Zap size={20} className="text-helios-solar" />
@@ -256,9 +277,7 @@ export default function Dashboard() {
</div>
<div>
<h4 className="text-sm font-bold text-helios-ink">{title}</h4>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
{body}
</p>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">{body}</p>
</div>
</div>
))}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { FileOutput, AlertTriangle, Save } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
interface FileSettings {
delete_source: boolean;
@@ -14,10 +15,11 @@ export default function FileSettings() {
delete_source: false,
output_extension: "mkv",
output_suffix: "-alchemist",
replace_strategy: "keep"
replace_strategy: "keep",
});
const [_loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchSettings();
@@ -25,29 +27,38 @@ export default function FileSettings() {
const fetchSettings = async () => {
try {
const res = await apiFetch("/api/settings/files");
if (res.ok) setSettings(await res.json());
} catch (e) { console.error(e); }
finally { setLoading(false); }
const data = await apiJson<FileSettings>("/api/settings/files");
setSettings(data);
setError(null);
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to load file settings";
setError(message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await apiFetch("/api/settings/files", {
await apiAction("/api/settings/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
body: JSON.stringify(settings),
});
showToast({ kind: "success", title: "Files", message: "File settings saved." });
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to save file settings";
setError(message);
showToast({ kind: "error", title: "Files", message });
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="space-y-6" aria-live="polite">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-helios-solar/10 rounded-lg">
<FileOutput className="text-helios-solar" size={20} />
@@ -58,66 +69,77 @@ export default function FileSettings() {
</div>
</div>
<div className="space-y-4">
{/* Naming */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Output Suffix</label>
<input
type="text"
value={settings.output_suffix}
onChange={e => setSettings({ ...settings, output_suffix: e.target.value })}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
placeholder="-alchemist"
/>
<p className="text-[10px] text-helios-slate mt-1">Appended to filename (e.g. video<span className="text-helios-solar">{settings.output_suffix}</span>.{settings.output_extension})</p>
</div>
<div>
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Extension</label>
<select
value={settings.output_extension}
onChange={e => setSettings({ ...settings, output_extension: e.target.value })}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
>
<option value="mkv">mkv</option>
<option value="mp4">mp4</option>
</select>
</div>
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
{/* Deletion Policy */}
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl space-y-3">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-500 shrink-0 mt-0.5" size={16} />
<div className="flex-1">
<h3 className="text-sm font-bold text-red-600 dark:text-red-400">Destructive Policy</h3>
<p className="text-xs text-helios-slate mt-1 mb-3">
Enabling "Delete Source" will permanently remove the original file after a successful transcode. This action cannot be undone.
{loading ? (
<div className="text-sm text-helios-slate animate-pulse">Loading settings</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Output Suffix</label>
<input
type="text"
value={settings.output_suffix}
onChange={e => setSettings({ ...settings, output_suffix: e.target.value })}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
placeholder="-alchemist"
/>
<p className="text-[10px] text-helios-slate mt-1">
Appended to filename (e.g. video
<span className="text-helios-solar">{settings.output_suffix}</span>.{settings.output_extension})
</p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.delete_source}
onChange={e => setSettings({ ...settings, delete_source: e.target.checked })}
className="rounded border-red-500/30 text-red-500 focus:ring-red-500 bg-red-500/10"
/>
<span className="text-sm font-medium text-helios-ink">Delete source file after success</span>
</label>
</div>
<div>
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Extension</label>
<select
value={settings.output_extension}
onChange={e => setSettings({ ...settings, output_extension: e.target.value })}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink font-mono"
>
<option value="mkv">mkv</option>
<option value="mp4">mp4</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-helios-solar text-helios-main font-bold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save Settings"}
</button>
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl space-y-3">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-500 shrink-0 mt-0.5" size={16} />
<div className="flex-1">
<h3 className="text-sm font-bold text-red-600 dark:text-red-400">Destructive Policy</h3>
<p className="text-xs text-helios-slate mt-1 mb-3">
Enabling "Delete Source" will permanently remove the original file after a successful transcode. This action cannot be undone.
</p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.delete_source}
onChange={e => setSettings({ ...settings, delete_source: e.target.checked })}
className="rounded border-red-500/30 text-red-500 focus:ring-red-500 bg-red-500/10"
/>
<span className="text-sm font-medium text-helios-ink">Delete source file after success</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-helios-solar text-helios-main font-bold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { Cpu, Zap, HardDrive, CheckCircle2, AlertCircle } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
interface HardwareInfo {
vendor: "Nvidia" | "Amd" | "Intel" | "Apple" | "Cpu";
@@ -23,33 +24,27 @@ export default function HardwareSettings() {
const [saving, setSaving] = useState(false);
useEffect(() => {
void fetchHardware();
void fetchSettings();
void Promise.all([fetchHardware(), fetchSettings()]).finally(() => setLoading(false));
}, []);
const fetchHardware = async () => {
try {
const res = await apiFetch("/api/system/hardware");
if (!res.ok) throw new Error("Failed to detect hardware");
const data = await res.json();
const data = await apiJson<HardwareInfo>("/api/system/hardware");
setInfo(data);
setError("");
} catch (err) {
setError("Unable to detect hardware acceleration support.");
console.error(err);
} finally {
setLoading(false);
setError(isApiError(err) ? err.message : "Unable to detect hardware acceleration support.");
}
};
const fetchSettings = async () => {
try {
const res = await apiFetch("/api/settings/hardware");
if (res.ok) {
const data = await res.json();
setSettings(data);
}
const data = await apiJson<HardwareSettings>("/api/settings/hardware");
setSettings(data);
} catch (err) {
console.error("Failed to fetch hardware settings:", err);
if (!error) {
setError(isApiError(err) ? err.message : "Failed to fetch hardware settings.");
}
}
};
@@ -57,16 +52,18 @@ export default function HardwareSettings() {
if (!settings) return;
setSaving(true);
try {
const res = await apiFetch("/api/settings/hardware", {
await apiAction("/api/settings/hardware", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...settings, allow_cpu_encoding: enabled }),
});
if (res.ok) {
setSettings({ ...settings, allow_cpu_encoding: enabled });
}
setSettings({ ...settings, allow_cpu_encoding: enabled });
setError("");
showToast({ kind: "success", title: "Hardware", message: "Hardware settings saved." });
} catch (err) {
console.error("Failed to update CPU encoding:", err);
const message = isApiError(err) ? err.message : "Failed to update CPU encoding";
setError(message);
showToast({ kind: "error", title: "Hardware", message });
} finally {
setSaving(false);
}
@@ -83,7 +80,7 @@ export default function HardwareSettings() {
if (error || !info) {
return (
<div className="p-6 bg-red-500/10 border border-red-500/20 text-red-500 rounded-2xl flex items-center gap-3">
<div className="p-6 bg-red-500/10 border border-red-500/20 text-red-500 rounded-2xl flex items-center gap-3" aria-live="polite">
<AlertCircle size={20} />
<span className="font-semibold">{error || "Hardware detection failed."}</span>
</div>
@@ -103,7 +100,7 @@ export default function HardwareSettings() {
const details = getVendorDetails(info.vendor);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6" aria-live="polite">
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
<div>
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">Transcoding Hardware</h3>
@@ -176,7 +173,6 @@ export default function HardwareSettings() {
</div>
)}
{/* CPU Encoding Toggle */}
{settings && (
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
<div className="flex items-center justify-between">
@@ -192,15 +188,11 @@ export default function HardwareSettings() {
</div>
</div>
<button
onClick={() => updateCpuEncoding(!settings.allow_cpu_encoding)}
onClick={() => void updateCpuEncoding(!settings.allow_cpu_encoding)}
disabled={saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${settings.allow_cpu_encoding ? 'bg-emerald-500' : 'bg-helios-line/50'
} ${saving ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${settings.allow_cpu_encoding ? "bg-emerald-500" : "bg-helios-line/50"} ${saving ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${settings.allow_cpu_encoding ? 'translate-x-6' : 'translate-x-1'
}`}
/>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${settings.allow_cpu_encoding ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
</div>

View File

@@ -2,15 +2,20 @@ import { useState } from "react";
import { Info, LogOut } from "lucide-react";
import { motion } from "framer-motion";
import AboutDialog from "./AboutDialog";
import { apiAction } from "../lib/api";
import { showToast } from "../lib/toast";
export default function HeaderActions() {
const [showAbout, setShowAbout] = useState(false);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
await apiAction("/api/auth/logout", { method: "POST" });
} catch {
// Ignore logout failures and continue redirecting to login.
showToast({
kind: "error",
message: "Logout request failed. Redirecting to login.",
});
} finally {
window.location.href = '/login';
}

View File

@@ -3,7 +3,10 @@ import {
Search, RefreshCw, Trash2, Ban,
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal
} from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { useDebouncedValue } from "../lib/useDebouncedValue";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { motion, AnimatePresence } from "framer-motion";
@@ -12,6 +15,21 @@ function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
function focusableElements(root: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(element) => !element.hasAttribute("disabled")
);
}
interface Job {
id: number;
input_path: string;
@@ -64,13 +82,18 @@ export default function JobManager() {
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState<TabType>("all");
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const debouncedSearch = useDebouncedValue(searchInput, 350);
const [page, setPage] = useState(1);
const [refreshing, setRefreshing] = useState(false);
const [focusedJob, setFocusedJob] = useState<JobDetail | null>(null);
const [_detailLoading, setDetailLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [menuJobId, setMenuJobId] = useState<number | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const detailDialogRef = useRef<HTMLDivElement | null>(null);
const detailLastFocusedRef = useRef<HTMLElement | null>(null);
const confirmOpenRef = useRef(false);
const [confirmState, setConfirmState] = useState<{
title: string;
body: string;
@@ -90,8 +113,10 @@ export default function JobManager() {
}
};
const fetchJobs = useCallback(async () => {
setRefreshing(true);
const fetchJobs = useCallback(async (silent = false) => {
if (!silent) {
setRefreshing(true);
}
try {
const params = new URLSearchParams({
limit: "50",
@@ -103,29 +128,54 @@ export default function JobManager() {
if (activeTab !== "all") {
params.set("status", getStatusFilter(activeTab));
}
if (search) {
params.set("search", search);
if (debouncedSearch) {
params.set("search", debouncedSearch);
}
const res = await apiFetch(`/api/jobs/table?${params}`);
if (res.ok) {
const data = await res.json();
setJobs(data);
}
const data = await apiJson<Job[]>(`/api/jobs/table?${params}`);
setJobs(data);
setActionError(null);
} catch (e) {
console.error("Failed to fetch jobs", e);
const message = isApiError(e) ? e.message : "Failed to fetch jobs";
setActionError(message);
if (!silent) {
showToast({ kind: "error", title: "Jobs", message });
}
} finally {
setLoading(false);
setRefreshing(false);
if (!silent) {
setRefreshing(false);
}
}
}, [activeTab, search, page]);
}, [activeTab, debouncedSearch, page]);
const fetchJobsRef = useRef<() => Promise<void>>(async () => undefined);
useEffect(() => {
void fetchJobs();
const interval = setInterval(fetchJobs, 5000); // Auto-refresh every 5s
return () => clearInterval(interval);
fetchJobsRef.current = async () => {
await fetchJobs(true);
};
}, [fetchJobs]);
useEffect(() => {
void fetchJobs(false);
}, [fetchJobs]);
useEffect(() => {
const pollVisible = () => {
if (document.visibilityState === "visible") {
void fetchJobsRef.current();
}
};
const interval = window.setInterval(pollVisible, 5000);
document.addEventListener("visibilitychange", pollVisible);
return () => {
window.clearInterval(interval);
document.removeEventListener("visibilitychange", pollVisible);
};
}, []);
useEffect(() => {
if (!menuJobId) return;
const handleClick = (event: MouseEvent) => {
@@ -137,6 +187,76 @@ export default function JobManager() {
return () => document.removeEventListener("mousedown", handleClick);
}, [menuJobId]);
useEffect(() => {
confirmOpenRef.current = confirmState !== null;
}, [confirmState]);
useEffect(() => {
if (!focusedJob) {
return;
}
detailLastFocusedRef.current = document.activeElement as HTMLElement | null;
const root = detailDialogRef.current;
if (root) {
const focusables = focusableElements(root);
if (focusables.length > 0) {
focusables[0].focus();
} else {
root.focus();
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (!focusedJob || confirmOpenRef.current) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
setFocusedJob(null);
return;
}
if (event.key !== "Tab") {
return;
}
const dialogRoot = detailDialogRef.current;
if (!dialogRoot) {
return;
}
const focusables = focusableElements(dialogRoot);
if (focusables.length === 0) {
event.preventDefault();
dialogRoot.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = document.activeElement as HTMLElement | null;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
if (detailLastFocusedRef.current) {
detailLastFocusedRef.current.focus();
}
};
}, [focusedJob]);
const toggleSelect = (id: number) => {
const newSet = new Set(selected);
if (newSet.has(id)) newSet.delete(id);
@@ -154,55 +274,77 @@ export default function JobManager() {
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
if (selected.size === 0) return;
setActionError(null);
try {
const res = await apiFetch("/api/jobs/batch", {
await apiAction("/api/jobs/batch", {
method: "POST",
body: JSON.stringify({
action,
ids: Array.from(selected)
})
});
if (res.ok) {
setSelected(new Set());
await fetchJobs();
}
setSelected(new Set());
showToast({
kind: "success",
title: "Jobs",
message: `${action[0].toUpperCase()}${action.slice(1)} request sent for selected jobs.`,
});
await fetchJobs();
} catch (e) {
console.error("Batch action failed", e);
const message = isApiError(e) ? e.message : "Batch action failed";
setActionError(message);
showToast({ kind: "error", title: "Jobs", message });
}
};
const clearCompleted = async () => {
await apiFetch("/api/jobs/clear-completed", { method: "POST" });
await fetchJobs();
setActionError(null);
try {
await apiAction("/api/jobs/clear-completed", { method: "POST" });
showToast({ kind: "success", title: "Jobs", message: "Completed jobs cleared." });
await fetchJobs();
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to clear completed jobs";
setActionError(message);
showToast({ kind: "error", title: "Jobs", message });
}
};
const fetchJobDetails = async (id: number) => {
setActionError(null);
setDetailLoading(true);
try {
const res = await apiFetch(`/api/jobs/${id}/details`);
if (res.ok) {
const data = await res.json();
setFocusedJob(data);
}
const data = await apiJson<JobDetail>(`/api/jobs/${id}/details`);
setFocusedJob(data);
} catch (e) {
console.error("Failed to fetch job details", e);
const message = isApiError(e) ? e.message : "Failed to fetch job details";
setActionError(message);
showToast({ kind: "error", title: "Jobs", message });
} finally {
setDetailLoading(false);
}
};
const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => {
setActionError(null);
try {
const res = await apiFetch(`/api/jobs/${id}/${action}`, { method: "POST" });
if (res.ok) {
if (action === "delete") setFocusedJob(null);
else await fetchJobDetails(id);
await fetchJobs();
await apiAction(`/api/jobs/${id}/${action}`, { method: "POST" });
if (action === "delete") {
setFocusedJob((current) => (current?.job.id === id ? null : current));
} else if (focusedJob?.job.id === id) {
await fetchJobDetails(id);
}
await fetchJobs();
showToast({
kind: "success",
title: "Jobs",
message: `Job ${action} request completed.`,
});
} catch (e) {
console.error(`Action ${action} failed`, e);
const message = isApiError(e) ? e.message : `Job ${action} failed`;
setActionError(message);
showToast({ kind: "error", title: "Jobs", message });
}
};
@@ -275,13 +417,13 @@ export default function JobManager() {
<input
type="text"
placeholder="Search files..."
value={search}
onChange={(e) => setSearch(e.target.value)}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-9 pr-4 py-2 text-sm text-helios-ink focus:border-helios-solar outline-none"
/>
</div>
<button
onClick={() => fetchJobs()}
onClick={() => void fetchJobs()}
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")}
>
<RefreshCw size={16} />
@@ -289,6 +431,12 @@ export default function JobManager() {
</div>
</div>
{actionError && (
<div role="alert" aria-live="polite" className="rounded-xl border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error">
{actionError}
</div>
)}
{/* Batch Actions Bar */}
{selected.size > 0 && (
<div className="flex items-center justify-between bg-helios-solar/10 border border-helios-solar/20 px-6 py-3 rounded-xl animate-in fade-in slide-in-from-top-2">
@@ -374,7 +522,7 @@ export default function JobManager() {
jobs.map((job) => (
<tr
key={job.id}
onClick={() => fetchJobDetails(job.id)}
onClick={() => void fetchJobDetails(job.id)}
className={cn(
"group hover:bg-helios-surface/80 transition-all cursor-pointer",
selected.has(job.id) && "bg-helios-surface-soft",
@@ -549,6 +697,12 @@ export default function JobManager() {
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2 }}
ref={detailDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="job-details-title"
aria-describedby="job-details-path"
tabIndex={-1}
className="w-full max-w-2xl bg-helios-surface border border-helios-line/20 rounded-2xl shadow-2xl pointer-events-auto overflow-hidden mx-4"
>
{/* Header */}
@@ -558,10 +712,10 @@ export default function JobManager() {
{getStatusBadge(focusedJob.job.status)}
<span className="text-[10px] uppercase font-bold tracking-widest text-helios-slate">Job ID #{focusedJob.job.id}</span>
</div>
<h2 className="text-lg font-bold text-helios-ink truncate" title={focusedJob.job.input_path}>
<h2 id="job-details-title" className="text-lg font-bold text-helios-ink truncate" title={focusedJob.job.input_path}>
{focusedJob.job.input_path.split(/[/\\]/).pop()}
</h2>
<p className="text-xs text-helios-slate truncate opacity-60">{focusedJob.job.input_path}</p>
<p id="job-details-path" className="text-xs text-helios-slate truncate opacity-60">{focusedJob.job.input_path}</p>
</div>
<button
onClick={() => setFocusedJob(null)}
@@ -572,6 +726,9 @@ export default function JobManager() {
</div>
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
{detailLoading && (
<p className="text-xs text-helios-slate" aria-live="polite">Loading job details...</p>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/10 space-y-1">
@@ -745,56 +902,20 @@ export default function JobManager() {
)}
</AnimatePresence>
{/* Confirm Modal */}
<AnimatePresence>
{confirmState && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setConfirmState(null)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[120]"
/>
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[121]">
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 12 }}
className="w-full max-w-sm bg-helios-surface border border-helios-line/20 rounded-2xl shadow-2xl pointer-events-auto overflow-hidden mx-4"
>
<div className="p-6 space-y-2">
<h3 className="text-lg font-bold text-helios-ink">{confirmState.title}</h3>
<p className="text-sm text-helios-slate">{confirmState.body}</p>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6">
<button
onClick={() => setConfirmState(null)}
className="px-4 py-2 text-sm font-semibold text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft rounded-lg"
>
Cancel
</button>
<button
onClick={async () => {
const action = confirmState.onConfirm;
setConfirmState(null);
await action();
}}
className={cn(
"px-4 py-2 text-sm font-semibold rounded-lg",
confirmState.confirmTone === "danger"
? "bg-red-500/15 text-red-500 hover:bg-red-500/25"
: "bg-helios-solar text-helios-main hover:brightness-110"
)}
>
{confirmState.confirmLabel}
</button>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
<ConfirmDialog
open={confirmState !== null}
title={confirmState?.title ?? ""}
description={confirmState?.body ?? ""}
confirmLabel={confirmState?.confirmLabel ?? "Confirm"}
tone={confirmState?.confirmTone ?? "primary"}
onClose={() => setConfirmState(null)}
onConfirm={async () => {
if (!confirmState) {
return;
}
await confirmState.onConfirm();
}}
/>
</div>
);
}

View File

@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from "react";
import { Terminal, Pause, Play, Trash2, RefreshCw } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -21,37 +23,39 @@ export default function LogViewer() {
const [paused, setPaused] = useState(false);
const [loading, setLoading] = useState(true);
const [streamError, setStreamError] = useState<string | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const pausedRef = useRef(paused);
const reconnectTimeoutRef = useRef<number | null>(null);
const maxLogs = 1000;
// Sync ref
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const fetchHistory = async () => {
setLoading(true);
try {
const res = await apiFetch("/api/logs/history?limit=200");
if (res.ok) {
const history = await res.json();
// Logs come newest first (DESC), reverse for display
setLogs(history.reverse());
}
const history = await apiJson<LogEntry[]>("/api/logs/history?limit=200");
setLogs(history.reverse());
setStreamError(null);
} catch (e) {
console.error("Failed to fetch logs", e);
const message = isApiError(e) ? e.message : "Failed to fetch logs";
setStreamError(message);
} finally {
setLoading(false);
}
};
const clearLogs = async () => {
if (!confirm("Are you sure you want to clear all server logs?")) return;
try {
await apiFetch("/api/logs", { method: "DELETE" });
await apiAction("/api/logs", { method: "DELETE" });
setLogs([]);
showToast({ kind: "success", title: "Logs", message: "Server logs cleared." });
} catch (e) {
console.error("Failed to clear logs", e);
const message = isApiError(e) ? e.message : "Failed to clear logs";
showToast({ kind: "error", title: "Logs", message });
}
};
@@ -60,74 +64,75 @@ export default function LogViewer() {
let eventSource: EventSource | null = null;
let cancelled = false;
const connect = () => {
if (cancelled) return;
setStreamError(null);
if (eventSource) {
eventSource.close();
eventSource = null;
}
eventSource = new EventSource('/api/events');
const handleMsg = (msg: string, level: string, job_id?: number) => {
if (pausedRef.current) return;
setStreamError(null);
eventSource?.close();
eventSource = new EventSource("/api/events");
const appendLog = (message: string, level: string, jobId?: number) => {
if (pausedRef.current) {
return;
}
const entry: LogEntry = {
id: Date.now() + Math.random(),
level,
message: msg,
job_id,
created_at: new Date().toISOString()
message,
job_id: jobId,
created_at: new Date().toISOString(),
};
setLogs(prev => {
const newLogs = [...prev, entry];
if (newLogs.length > maxLogs) return newLogs.slice(newLogs.length - maxLogs);
return newLogs;
setLogs((prev) => {
const next = [...prev, entry];
if (next.length > maxLogs) {
return next.slice(next.length - maxLogs);
}
return next;
});
};
eventSource.addEventListener("log", (e) => {
eventSource.addEventListener("log", (event) => {
const data = event.data;
try {
// Expecting simple text or JSON?
// Backend sends AlchemistEvent::Log { level, job_id, message }
// But SSE serializer matches structure.
// Wait, existing SSE in server.rs sends plain text or JSON?
// Let's check server.rs sse_handler or Event impl.
// Assuming existing impl sends `data: message` for "log" event.
// But I added structured event in backend: AlchemistEvent::Log
// If server.rs uses `sse::Event::default().event("log").data(...)`
const parsed = JSON.parse(data) as { message?: string; level?: string; job_id?: number };
if (parsed.message) {
appendLog(parsed.message, parsed.level ?? "info", parsed.job_id);
return;
}
} catch {
// Fall back to plain text handling.
}
// Actually, I need to check `sse_handler` in `server.rs` to see what it sends.
// Assuming it sends JSON for structured events or adapts.
// If it used to send string, I should support string.
const data = e.data;
// Try parsing JSON first
try {
const json = JSON.parse(data);
if (json.message) {
handleMsg(json.message, json.level || "info", json.job_id);
return;
}
} catch { }
// Fallback to text
handleMsg(data, data.toLowerCase().includes("error") ? "error" : "info");
} catch { }
appendLog(data, data.toLowerCase().includes("error") ? "error" : "info");
});
eventSource.addEventListener("decision", (e) => {
try { const d = JSON.parse(e.data); handleMsg(`Decision: ${d.action.toUpperCase()} - ${d.reason}`, "info", d.job_id); } catch { }
eventSource.addEventListener("decision", (event) => {
try {
const data = JSON.parse(event.data) as { action: string; reason: string; job_id?: number };
appendLog(`Decision: ${data.action.toUpperCase()} - ${data.reason}`, "info", data.job_id);
} catch {
// Ignore malformed SSE payload.
}
});
eventSource.addEventListener("status", (e) => {
try { const d = JSON.parse(e.data); handleMsg(`Status changed to ${d.status}`, "info", d.job_id); } catch { }
eventSource.addEventListener("status", (event) => {
try {
const data = JSON.parse(event.data) as { status: string; job_id?: number };
appendLog(`Status changed to ${data.status}`, "info", data.job_id);
} catch {
// Ignore malformed SSE payload.
}
});
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
setStreamError("Log stream unavailable. Please check authentication.");
if (reconnectTimeoutRef.current) {
setStreamError("Log stream unavailable. Reconnecting…");
if (reconnectTimeoutRef.current !== null) {
window.clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = window.setTimeout(connect, 3000);
@@ -137,7 +142,7 @@ export default function LogViewer() {
connect();
return () => {
cancelled = true;
if (reconnectTimeoutRef.current) {
if (reconnectTimeoutRef.current !== null) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
@@ -145,7 +150,6 @@ export default function LogViewer() {
};
}, []);
// Auto-scroll
useEffect(() => {
if (!paused && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -155,20 +159,22 @@ export default function LogViewer() {
const formatTime = (iso: string) => {
try {
return new Date(iso).toLocaleTimeString();
} catch { return iso; }
} catch {
return iso;
}
};
return (
<div className="flex flex-col h-full rounded-2xl border border-helios-line/40 bg-[#0d1117] overflow-hidden shadow-2xl">
<div className="flex items-center justify-between px-4 py-3 border-b border-helios-line/20 bg-helios-surface/50 backdrop-blur">
<div className="flex items-center gap-2 text-helios-slate">
<div className="flex items-center gap-2 text-helios-slate" aria-live="polite">
<Terminal size={16} />
<span className="text-xs font-bold uppercase tracking-widest">Server Logs</span>
{loading && <span className="text-xs animate-pulse opacity-50 ml-2">Loading history...</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchHistory}
onClick={() => void fetchHistory()}
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
title="Reload History"
>
@@ -182,7 +188,7 @@ export default function LogViewer() {
{paused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
onClick={clearLogs}
onClick={() => setConfirmClear(true)}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-helios-slate hover:text-red-400 transition-colors"
title="Clear Server Logs"
>
@@ -194,12 +200,9 @@ export default function LogViewer() {
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-1 scrollbar-thin scrollbar-thumb-helios-line/20 scrollbar-track-transparent"
aria-live="polite"
>
{streamError && (
<div className="text-amber-400 text-center py-4 text-[11px] font-semibold">
{streamError}
</div>
)}
{streamError && <div className="text-amber-400 text-center py-4 text-[11px] font-semibold">{streamError}</div>}
{logs.length === 0 && !loading && !streamError && (
<div className="text-helios-slate/30 text-center py-10 italic">No logs found.</div>
)}
@@ -209,19 +212,35 @@ export default function LogViewer() {
<div className="flex-1 min-w-0 break-all">
{log.job_id && (
<span className="inline-block px-1.5 py-0.5 rounded bg-white/5 text-helios-slate/80 mr-2 text-[10px]">#{log.job_id}</span>
<span className="inline-block px-1.5 py-0.5 rounded bg-white/5 text-helios-slate/80 mr-2 text-[10px]">
#{log.job_id}
</span>
)}
<span className={cn(
log.level.toLowerCase().includes("error") ? "text-red-400 font-bold" :
log.level.toLowerCase().includes("warn") ? "text-amber-400" :
"text-white/90"
)}>
<span
className={cn(
log.level.toLowerCase().includes("error")
? "text-red-400 font-bold"
: log.level.toLowerCase().includes("warn")
? "text-amber-400"
: "text-white/90"
)}
>
{log.message}
</span>
</div>
</div>
))}
</div>
<ConfirmDialog
open={confirmClear}
title="Clear server logs"
description="Delete all stored server logs?"
confirmLabel="Clear Logs"
tone="danger"
onClose={() => setConfirmClear(false)}
onConfirm={clearLogs}
/>
</div>
);
}

View File

@@ -1,29 +1,34 @@
import { useState, useEffect } from "react";
import { Bell, Plus, Trash2, Zap } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
interface NotificationTarget {
id: number;
name: string;
target_type: 'gotify' | 'discord' | 'webhook';
target_type: "gotify" | "discord" | "webhook";
endpoint_url: string;
auth_token?: string;
events: string; // JSON string
events: string;
enabled: boolean;
}
const TARGET_TYPES: NotificationTarget["target_type"][] = ["discord", "gotify", "webhook"];
export default function NotificationSettings() {
const [targets, setTargets] = useState<NotificationTarget[]>([]);
const [loading, setLoading] = useState(true);
const [testingId, setTestingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [showForm, setShowForm] = useState(false);
const [newName, setNewName] = useState("");
const [newType, setNewType] = useState<NotificationTarget['target_type']>("discord");
const [newType, setNewType] = useState<NotificationTarget["target_type"]>("discord");
const [newUrl, setNewUrl] = useState("");
const [newToken, setNewToken] = useState("");
const [newEvents, setNewEvents] = useState<string[]>(["completed", "failed"]);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
useEffect(() => {
void fetchTargets();
@@ -31,13 +36,12 @@ export default function NotificationSettings() {
const fetchTargets = async () => {
try {
const res = await apiFetch("/api/settings/notifications");
if (res.ok) {
const data = await res.json();
setTargets(data);
}
const data = await apiJson<NotificationTarget[]>("/api/settings/notifications");
setTargets(data);
setError(null);
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to load notification targets";
setError(message);
} finally {
setLoading(false);
}
@@ -46,7 +50,7 @@ export default function NotificationSettings() {
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await apiFetch("/api/settings/notifications", {
await apiAction("/api/settings/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -55,28 +59,33 @@ export default function NotificationSettings() {
endpoint_url: newUrl,
auth_token: newToken || null,
events: newEvents,
enabled: true
})
enabled: true,
}),
});
if (res.ok) {
setShowForm(false);
setNewName("");
setNewUrl("");
setNewToken("");
await fetchTargets();
}
setShowForm(false);
setNewName("");
setNewUrl("");
setNewToken("");
setError(null);
await fetchTargets();
showToast({ kind: "success", title: "Notifications", message: "Target added." });
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to add notification target";
setError(message);
showToast({ kind: "error", title: "Notifications", message });
}
};
const handleDelete = async (id: number) => {
if (!confirm("Remove this notification target?")) return;
try {
await apiFetch(`/api/settings/notifications/${id}`, { method: "DELETE" });
await apiAction(`/api/settings/notifications/${id}`, { method: "DELETE" });
setError(null);
await fetchTargets();
showToast({ kind: "success", title: "Notifications", message: "Target removed." });
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to remove target";
setError(message);
showToast({ kind: "error", title: "Notifications", message });
}
};
@@ -93,7 +102,7 @@ export default function NotificationSettings() {
events = [];
}
const res = await apiFetch("/api/settings/notifications/test", {
await apiAction("/api/settings/notifications/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -101,19 +110,16 @@ export default function NotificationSettings() {
target_type: target.target_type,
endpoint_url: target.endpoint_url,
auth_token: target.auth_token,
events: events,
enabled: target.enabled
})
events,
enabled: target.enabled,
}),
});
if (res.ok) {
alert("Test notification sent!");
} else {
alert("Test failed.");
}
showToast({ kind: "success", title: "Notifications", message: "Test notification sent." });
} catch (e) {
console.error(e);
alert("Test error");
const message = isApiError(e) ? e.message : "Test notification failed";
setError(message);
showToast({ kind: "error", title: "Notifications", message });
} finally {
setTestingId(null);
}
@@ -128,7 +134,7 @@ export default function NotificationSettings() {
};
return (
<div className="space-y-6">
<div className="space-y-6" aria-live="polite">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-helios-solar/10 rounded-lg">
@@ -148,6 +154,12 @@ export default function NotificationSettings() {
</button>
</div>
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
{showForm && (
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -165,12 +177,14 @@ export default function NotificationSettings() {
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Type</label>
<select
value={newType}
onChange={e => setNewType(e.target.value as any)}
onChange={e => setNewType(e.target.value as NotificationTarget["target_type"])}
className="w-full bg-helios-surface border border-helios-line/20 rounded p-2 text-sm text-helios-ink"
>
<option value="discord">Discord Webhook</option>
<option value="gotify">Gotify</option>
<option value="webhook">Generic Webhook</option>
{TARGET_TYPES.map((type) => (
<option key={type} value={type}>
{type === "discord" ? "Discord Webhook" : type === "gotify" ? "Gotify" : "Generic Webhook"}
</option>
))}
</select>
</div>
</div>
@@ -199,7 +213,7 @@ export default function NotificationSettings() {
<div>
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Events</label>
<div className="flex gap-4 flex-wrap">
{['completed', 'failed', 'queued'].map(evt => (
{["completed", "failed", "queued"].map(evt => (
<label key={evt} className="flex items-center gap-2 text-sm text-helios-ink cursor-pointer">
<input
type="checkbox"
@@ -219,50 +233,60 @@ export default function NotificationSettings() {
</form>
)}
<div className="space-y-3">
{targets.map(target => (
<div key={target.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl group/item">
<div className="flex items-center gap-4">
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
<Zap size={18} />
</div>
<div>
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] uppercase font-bold tracking-wider text-helios-slate bg-helios-surface-soft px-1.5 rounded">
{target.target_type}
</span>
<span className="text-xs text-helios-slate truncate max-w-[200px]">
{target.endpoint_url}
</span>
{loading ? (
<div className="text-sm text-helios-slate animate-pulse">Loading targets</div>
) : (
<div className="space-y-3">
{targets.map(target => (
<div key={target.id} className="flex items-center justify-between p-4 bg-helios-surface border border-helios-line/10 rounded-xl group/item">
<div className="flex items-center gap-4">
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
<Zap size={18} />
</div>
<div>
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] uppercase font-bold tracking-wider text-helios-slate bg-helios-surface-soft px-1.5 rounded">
{target.target_type}
</span>
<span className="text-xs text-helios-slate truncate max-w-[200px]">{target.endpoint_url}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleTest(target)}
disabled={testingId === target.id}
className="p-2 text-helios-slate hover:text-helios-solar hover:bg-helios-solar/10 rounded-lg transition-colors"
title="Test Notification"
>
<Zap size={16} className={testingId === target.id ? "animate-pulse" : ""} />
</button>
<button
onClick={() => setPendingDeleteId(target.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
aria-label={`Delete notification target ${target.name}`}
>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(target)}
disabled={testingId === target.id}
className="p-2 text-helios-slate hover:text-helios-solar hover:bg-helios-solar/10 rounded-lg transition-colors"
title="Test Notification"
>
<Zap size={16} className={testingId === target.id ? "animate-pulse" : ""} />
</button>
<button
onClick={() => handleDelete(target.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
))}
</div>
)}
{targets.length === 0 && !loading && (
<div className="text-center py-8 text-helios-slate text-sm">
No notification targets configured.
</div>
)}
</div>
<ConfirmDialog
open={pendingDeleteId !== null}
title="Remove notification target"
description="Remove this notification target?"
confirmLabel="Remove"
tone="danger"
onClose={() => setPendingDeleteId(null)}
onConfirm={async () => {
if (pendingDeleteId === null) return;
await handleDelete(pendingDeleteId);
}}
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { apiFetch } from '../lib/api';
import { Activity, Cpu, HardDrive, Clock, Layers } from 'lucide-react';
import { motion } from 'framer-motion';
import { useEffect, useState } from "react";
import { apiJson, isApiError } from "../lib/api";
import { Activity, Cpu, HardDrive, Clock, Layers } from "lucide-react";
import { motion } from "framer-motion";
interface SystemResources {
cpu_percent: number;
@@ -20,43 +20,84 @@ interface SystemSettings {
monitoring_poll_interval: number;
}
const MIN_INTERVAL_MS = 500;
const MAX_INTERVAL_MS = 10000;
export default function ResourceMonitor() {
const [stats, setStats] = useState<SystemResources | null>(null);
const [error, setError] = useState<string | null>(null);
const [pollInterval, setPollInterval] = useState<number>(2000);
// Fetch settings once on mount
useEffect(() => {
apiFetch('/api/settings/system')
.then(res => res.json())
.then((data: SystemSettings) => {
void apiJson<SystemSettings>("/api/settings/system")
.then((data) => {
const seconds = Number(data?.monitoring_poll_interval);
if (!Number.isFinite(seconds)) return;
if (!Number.isFinite(seconds)) {
return;
}
const intervalMs = Math.round(seconds * 1000);
setPollInterval(Math.min(10000, Math.max(500, intervalMs)));
setPollInterval(Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, intervalMs)));
})
.catch(err => console.error('Failed to load system settings', err));
.catch(() => {
// Keep default poll interval if settings endpoint is unavailable.
});
}, []);
useEffect(() => {
const fetchStats = async () => {
let timer: number | null = null;
let mounted = true;
const run = async () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
schedule(pollInterval * 3);
return;
}
try {
const res = await apiFetch('/api/system/resources');
if (res.ok) {
const data = await res.json();
setStats(data);
setError(null);
} else {
setError('Failed to fetch resources');
const data = await apiJson<SystemResources>("/api/system/resources");
if (!mounted) {
return;
}
} catch (e) {
setError('Connection error');
setStats(data);
setError(null);
} catch (err) {
if (!mounted) {
return;
}
setError(isApiError(err) ? err.message : "Connection error");
} finally {
schedule(pollInterval);
}
};
void fetchStats();
const interval = setInterval(fetchStats, pollInterval);
return () => clearInterval(interval);
const schedule = (delayMs: number) => {
if (!mounted) {
return;
}
if (timer !== null) {
window.clearTimeout(timer);
}
timer = window.setTimeout(() => {
void run();
}, delayMs);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
void run();
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
void run();
return () => {
mounted = false;
document.removeEventListener("visibilitychange", onVisibilityChange);
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [pollInterval]);
const formatUptime = (seconds: number) => {
@@ -70,35 +111,32 @@ export default function ResourceMonitor() {
};
const getUsageColor = (percent: number) => {
if (percent > 90) return 'text-red-500 bg-red-500/10';
if (percent > 70) return 'text-yellow-500 bg-yellow-500/10';
return 'text-green-500 bg-green-500/10';
if (percent > 90) return "text-red-500 bg-red-500/10";
if (percent > 70) return "text-yellow-500 bg-yellow-500/10";
return "text-green-500 bg-green-500/10";
};
const getBarColor = (percent: number) => {
if (percent > 90) return 'bg-red-500';
if (percent > 70) return 'bg-yellow-500';
return 'bg-green-500';
if (percent > 90) return "bg-red-500";
if (percent > 70) return "bg-yellow-500";
return "bg-green-500";
};
if (!stats) return (
<div className={`p-6 rounded-2xl bg-white/5 border border-white/10 h-48 flex items-center justify-center ${error ? "" : "animate-pulse"}`}>
<div className="text-center">
<div className={`text-sm ${error ? "text-red-400" : "text-white/40"}`}>
{error ? "Unable to load system stats." : "Loading system stats..."}
</div>
{error && (
<div className="text-[10px] text-white/40 mt-2">
{error} Retrying automatically...
if (!stats) {
return (
<div className={`p-6 rounded-2xl bg-white/5 border border-white/10 h-48 flex items-center justify-center ${error ? "" : "animate-pulse"}`}>
<div className="text-center" aria-live="polite">
<div className={`text-sm ${error ? "text-red-400" : "text-white/40"}`}>
{error ? "Unable to load system stats." : "Loading system stats..."}
</div>
)}
{error && <div className="text-[10px] text-white/40 mt-2">{error} Retrying automatically...</div>}
</div>
</div>
</div>
);
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-5 gap-3">
{/* CPU Usage */}
<div className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-5 gap-3" aria-live="polite">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@@ -126,7 +164,6 @@ export default function ResourceMonitor() {
</div>
</motion.div>
{/* Memory Usage */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@@ -155,7 +192,6 @@ export default function ResourceMonitor() {
</div>
</motion.div>
{/* Active Jobs */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@@ -166,23 +202,22 @@ export default function ResourceMonitor() {
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
<Layers size={16} /> Active Jobs
</div>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400`}>
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400">
{stats.active_jobs} / {stats.concurrent_limit}
</span>
</div>
<div className="flex items-end gap-1 h-8 mt-2">
{/* Visual representation of job slots */}
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
<div
key={i}
className={`flex-1 rounded-sm transition-all duration-300 ${i < stats.active_jobs ? 'bg-blue-500 h-6' : 'bg-white/10 h-2'
}`}
className={`flex-1 rounded-sm transition-all duration-300 ${
i < stats.active_jobs ? "bg-blue-500 h-6" : "bg-white/10 h-2"
}`}
/>
))}
</div>
</motion.div>
{/* GPU Usage */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@@ -198,9 +233,7 @@ export default function ResourceMonitor() {
{stats.gpu_utilization.toFixed(1)}%
</span>
) : (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-white/10 text-white/40">
N/A
</span>
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-white/10 text-white/40">N/A</span>
)}
</div>
<div className="space-y-1">
@@ -219,7 +252,6 @@ export default function ResourceMonitor() {
</div>
</motion.div>
{/* Uptime */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@@ -232,12 +264,8 @@ export default function ResourceMonitor() {
</div>
<Activity size={14} className="text-green-500 animate-pulse" />
</div>
<div className="text-2xl font-bold text-white/90">
{formatUptime(stats.uptime_seconds)}
</div>
<div className="text-2xl font-bold text-white/90">{formatUptime(stats.uptime_seconds)}</div>
</motion.div>
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { useState, useEffect } from "react";
import { Clock, Plus, Trash2, Calendar } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
interface ScheduleWindow {
id: number;
start_time: string; // HH:MM
end_time: string; // HH:MM
days_of_week: string; // JSON array of ints
start_time: string;
end_time: string;
days_of_week: string;
enabled: boolean;
}
@@ -14,12 +16,14 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export default function ScheduleSettings() {
const [windows, setWindows] = useState<ScheduleWindow[]>([]);
const [_loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newStart, setNewStart] = useState("00:00");
const [newEnd, setNewEnd] = useState("08:00");
const [selectedDays, setSelectedDays] = useState<number[]>([0, 1, 2, 3, 4, 5, 6]);
const [showForm, setShowForm] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
useEffect(() => {
void fetchSchedule();
@@ -27,13 +31,12 @@ export default function ScheduleSettings() {
const fetchSchedule = async () => {
try {
const res = await apiFetch("/api/settings/schedule");
if (res.ok) {
const data = await res.json();
setWindows(data);
}
const data = await apiJson<ScheduleWindow[]>("/api/settings/schedule");
setWindows(data);
setError(null);
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to load schedule windows";
setError(message);
} finally {
setLoading(false);
}
@@ -42,32 +45,37 @@ export default function ScheduleSettings() {
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await apiFetch("/api/settings/schedule", {
await apiAction("/api/settings/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_time: newStart,
end_time: newEnd,
days_of_week: selectedDays,
enabled: true
})
enabled: true,
}),
});
if (res.ok) {
setShowForm(false);
await fetchSchedule();
}
setShowForm(false);
setError(null);
await fetchSchedule();
showToast({ kind: "success", title: "Schedule", message: "Schedule added." });
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to add schedule";
setError(message);
showToast({ kind: "error", title: "Schedule", message });
}
};
const handleDelete = async (id: number) => {
if (!confirm("Remove this schedule?")) return;
try {
await apiFetch(`/api/settings/schedule/${id}`, { method: "DELETE" });
await apiAction(`/api/settings/schedule/${id}`, { method: "DELETE" });
setError(null);
await fetchSchedule();
showToast({ kind: "success", title: "Schedule", message: "Schedule removed." });
} catch (e) {
console.error(e);
const message = isApiError(e) ? e.message : "Failed to remove schedule";
setError(message);
showToast({ kind: "error", title: "Schedule", message });
}
};
@@ -88,7 +96,7 @@ export default function ScheduleSettings() {
};
return (
<div className="space-y-6">
<div className="space-y-6" aria-live="polite">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-helios-solar/10 rounded-lg">
@@ -108,7 +116,15 @@ export default function ScheduleSettings() {
</button>
</div>
{windows.length > 0 ? (
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-helios-slate animate-pulse">Loading schedules</div>
) : windows.length > 0 ? (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-xl mb-4">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium flex items-center gap-2">
<Calendar size={14} />
@@ -157,10 +173,11 @@ export default function ScheduleSettings() {
key={day}
type="button"
onClick={() => toggleDay(idx)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${selectedDays.includes(idx)
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${
selectedDays.includes(idx)
? "bg-helios-solar text-helios-main"
: "bg-helios-surface border border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
}`}
}`}
>
{day}
</button>
@@ -192,7 +209,7 @@ export default function ScheduleSettings() {
{DAYS.map((day, idx) => {
const active = parseDays(win.days_of_week).includes(idx);
return (
<span key={day} className={`text-[10px] font-bold px-1.5 rounded ${active ? 'text-helios-solar bg-helios-solar/10' : 'text-helios-slate/30'}`}>
<span key={day} className={`text-[10px] font-bold px-1.5 rounded ${active ? "text-helios-solar bg-helios-solar/10" : "text-helios-slate/30"}`}>
{day}
</span>
);
@@ -200,14 +217,28 @@ export default function ScheduleSettings() {
</div>
</div>
<button
onClick={() => handleDelete(win.id)}
onClick={() => setPendingDeleteId(win.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
aria-label={`Delete schedule ${win.start_time}-${win.end_time}`}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
<ConfirmDialog
open={pendingDeleteId !== null}
title="Remove schedule"
description="Remove this schedule window?"
confirmLabel="Remove"
tone="danger"
onClose={() => setPendingDeleteId(null)}
onConfirm={async () => {
if (pendingDeleteId === null) return;
await handleDelete(pendingDeleteId);
}}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ArrowRight,
@@ -10,6 +10,8 @@ import {
Video,
} from 'lucide-react';
import clsx from 'clsx';
import { apiAction, apiJson, isApiError } from '../lib/api';
import { showToast } from '../lib/toast';
interface ConfigState {
// Auth
@@ -46,9 +48,9 @@ export default function SetupWizard() {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [_success, _setSuccess] = useState(false);
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
const [scanError, setScanError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
const [config, setConfig] = useState<ConfigState>({
@@ -69,14 +71,17 @@ export default function SetupWizard() {
useEffect(() => {
const loadSetupDefaults = async () => {
try {
const res = await fetch('/api/setup/status', { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
if (typeof data.enable_telemetry === 'boolean') {
setConfig(prev => ({ ...prev, enable_telemetry: data.enable_telemetry }));
const data = await apiJson<{ enable_telemetry?: boolean }>('/api/setup/status');
const telemetryEnabled = data.enable_telemetry;
if (typeof telemetryEnabled === 'boolean') {
setConfig(prev => ({ ...prev, enable_telemetry: telemetryEnabled }));
}
} catch (e) {
console.error("Failed to load setup defaults", e);
showToast({
kind: "info",
title: "Setup",
message: "Unable to load setup defaults; using built-in defaults.",
});
}
};
@@ -92,6 +97,13 @@ export default function SetupWizard() {
};
}, []);
const clearScanPolling = () => {
if (scanIntervalRef.current !== null) {
window.clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
};
const handleNext = async () => {
if (step === 1 && (!config.username || !config.password)) {
setError("Please fill in both username and password.");
@@ -103,16 +115,11 @@ export default function SetupWizard() {
if (!hardware) {
setLoading(true);
try {
const res = await fetch('/api/system/hardware', {
credentials: 'same-origin'
});
if (!res.ok) {
throw new Error(`Hardware detection failed (${res.status})`);
}
const data = await res.json();
const data = await apiJson<HardwareInfo>('/api/system/hardware');
setHardware(data);
} catch (e) {
console.error("Hardware detection failed", e);
const message = isApiError(e) ? e.message : "Hardware detection failed";
setError(message);
} finally {
setLoading(false);
}
@@ -131,58 +138,47 @@ export default function SetupWizard() {
const handleBack = () => setStep(s => Math.max(s - 1, 1));
const startScan = async () => {
setLoading(true);
try {
const res = await fetch('/api/scan/start', {
method: 'POST',
credentials: 'same-origin'
});
if (!res.ok) {
throw new Error(await res.text());
}
await pollScanStatus();
} catch (e) {
console.error("Failed to start scan", e);
setError("Failed to start scan. Please check authentication.");
}
};
const pollScanStatus = async () => {
if (scanIntervalRef.current !== null) {
window.clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
clearScanPolling();
scanIntervalRef.current = window.setInterval(async () => {
const poll = async () => {
try {
const res = await fetch('/api/scan/status', {
credentials: 'same-origin'
});
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json();
const data = await apiJson<ScanStatus>('/api/scan/status');
setScanStatus(data);
setScanError(null);
if (!data.is_running) {
if (scanIntervalRef.current !== null) {
window.clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
clearScanPolling();
setLoading(false);
}
} catch (e) {
console.error("Polling failed", e);
setError("Scan status unavailable. Please refresh and try again.");
if (scanIntervalRef.current !== null) {
window.clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
const message = isApiError(e) ? e.message : "Scan status unavailable";
setScanError(message);
clearScanPolling();
setLoading(false);
}
};
await poll();
scanIntervalRef.current = window.setInterval(() => {
void poll();
}, 1000);
};
const startScan = async () => {
setLoading(true);
setScanError(null);
try {
await apiAction('/api/scan/start', {
method: 'POST',
});
await pollScanStatus();
} catch (e) {
const message = isApiError(e) ? e.message : "Failed to start scan";
setScanError(message);
setLoading(false);
}
};
const addDirectory = () => {
if (dirInput && !config.directories.includes(dirInput)) {
setConfig(prev => ({
@@ -204,27 +200,22 @@ export default function SetupWizard() {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/setup/complete', {
await apiAction('/api/setup/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify(config)
});
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
await res.json();
setStep(5); // Move to Scan Progress
setScanStatus(null);
setScanError(null);
await startScan();
} catch (err: any) {
setError(err.message || "Failed to save configuration");
} catch (err) {
const message = isApiError(err) ? err.message : "Failed to save configuration";
setError(message);
} finally {
setLoading(false);
}
@@ -472,6 +463,7 @@ export default function SetupWizard() {
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="space-y-8 py-10 text-center"
aria-live="polite"
>
<div className="flex justify-center">
<div className="relative">
@@ -486,6 +478,32 @@ export default function SetupWizard() {
<p className="text-sm text-helios-slate">Building your transcoding queue. This might take a moment.</p>
</div>
{scanError && (
<div className="p-4 rounded-xl border border-status-error/30 bg-status-error/10 text-status-error text-sm space-y-3">
<p className="font-semibold">Scan failed or became unavailable.</p>
<p>{scanError}</p>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={() => void startScan()}
disabled={loading}
className="flex-1 py-2 rounded-lg bg-status-error/20 hover:bg-status-error/30 transition-colors"
>
Retry Scan
</button>
<button
onClick={() => {
clearScanPolling();
setScanError(null);
setStep(4);
}}
className="flex-1 py-2 rounded-lg border border-status-error/40 hover:bg-status-error/10 transition-colors"
>
Back to Review
</button>
</div>
</div>
)}
{scanStatus && (
<div className="space-y-3">
<div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-helios-slate">
@@ -540,7 +558,7 @@ export default function SetupWizard() {
disabled={loading}
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-helios-solar text-helios-main font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Searching..." : step === 4 ? "Build Engine" : "Next"}
{loading ? "Working..." : step === 4 ? "Build Engine" : "Next"}
{!loading && <ArrowRight size={18} />}
</button>
</div>

View File

@@ -8,9 +8,10 @@ import {
Activity,
Gauge,
FileVideo,
Timer
Timer,
type LucideIcon,
} from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiJson, isApiError } from "../lib/api";
interface AggregatedStats {
total_input_bytes: number;
@@ -47,6 +48,7 @@ export default function StatsCharts() {
const [dailyStats, setDailyStats] = useState<DailyStats[]>([]);
const [detailedStats, setDetailedStats] = useState<DetailedStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchAllStats();
@@ -54,17 +56,17 @@ export default function StatsCharts() {
const fetchAllStats = async () => {
try {
const [aggRes, dailyRes, detailedRes] = await Promise.all([
apiFetch("/api/stats/aggregated"),
apiFetch("/api/stats/daily"),
apiFetch("/api/stats/detailed")
const [aggData, dailyData, detailedData] = await Promise.all([
apiJson<AggregatedStats>("/api/stats/aggregated"),
apiJson<DailyStats[]>("/api/stats/daily"),
apiJson<DetailedStats[]>("/api/stats/detailed")
]);
if (aggRes.ok) setStats(await aggRes.json());
if (dailyRes.ok) setDailyStats(await dailyRes.json());
if (detailedRes.ok) setDetailedStats(await detailedRes.json());
setStats(aggData);
setDailyStats(dailyData);
setDetailedStats(detailedData);
setError(null);
} catch (e) {
console.error("Failed to fetch stats", e);
setError(isApiError(e) ? e.message : "Failed to fetch statistics");
} finally {
setLoading(false);
}
@@ -128,7 +130,15 @@ export default function StatsCharts() {
// Find max for bar chart scaling
const maxDailyJobs = Math.max(...dailyStats.map(d => d.jobs_completed), 1);
const StatCard = ({ icon: Icon, label, value, subtext, colorClass }: any) => (
interface StatCardProps {
icon: LucideIcon;
label: string;
value: string;
subtext?: string;
colorClass: string;
}
const StatCard = ({ icon: Icon, label, value, subtext, colorClass }: StatCardProps) => (
<div className="p-6 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="flex items-start justify-between">
<div>
@@ -143,7 +153,14 @@ export default function StatsCharts() {
</div>
);
const MetricCard = ({ icon: Icon, label, value, colorClass }: any) => (
interface MetricCardProps {
icon: LucideIcon;
label: string;
value: string;
colorClass: string;
}
const MetricCard = ({ icon: Icon, label, value, colorClass }: MetricCardProps) => (
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/20 flex items-center gap-3">
<div className={`p-2 rounded-lg ${colorClass} bg-opacity-10`}>
<Icon size={18} className={colorClass} />
@@ -157,6 +174,11 @@ export default function StatsCharts() {
return (
<div className="space-y-6">
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
{/* Main Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { Activity, Save } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
interface SystemSettingsPayload {
monitoring_poll_interval: number;
@@ -20,13 +21,11 @@ export default function SystemSettings() {
const fetchSettings = async () => {
try {
const res = await apiFetch("/api/settings/system");
if (!res.ok) throw new Error("Failed to load settings");
const data = await res.json();
const data = await apiJson<SystemSettingsPayload>("/api/settings/system");
setSettings(data);
setError("");
} catch (err) {
setError("Unable to load system settings.");
console.error(err);
setError(isApiError(err) ? err.message : "Unable to load system settings.");
} finally {
setLoading(false);
}
@@ -39,15 +38,17 @@ export default function SystemSettings() {
setSuccess(false);
try {
const res = await apiFetch("/api/settings/system", {
await apiAction("/api/settings/system", {
method: "POST",
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error("Failed to save settings");
setSuccess(true);
showToast({ kind: "success", title: "System", message: "System settings saved." });
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError("Failed to save settings.");
const message = isApiError(err) ? err.message : "Failed to save settings.";
setError(message);
showToast({ kind: "error", title: "System", message });
} finally {
setSaving(false);
}
@@ -62,7 +63,7 @@ export default function SystemSettings() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6" aria-live="polite">
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
<div>
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">System Monitoring</h3>
@@ -74,9 +75,7 @@ export default function SystemSettings() {
</div>
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-500 rounded-xl text-sm font-semibold">
{error}
</div>
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-500 rounded-xl text-sm font-semibold">{error}</div>
)}
{success && (
@@ -103,7 +102,9 @@ export default function SystemSettings() {
{settings.monitoring_poll_interval.toFixed(1)}s
</span>
</div>
<p className="text-[10px] text-helios-slate ml-1 pt-1">Determine how frequently the dashboard updates system stats. Lower values update faster but use slightly more CPU. Default is 2.0s.</p>
<p className="text-[10px] text-helios-slate ml-1 pt-1">
Determine how frequently the dashboard updates system stats. Lower values update faster but use slightly more CPU. Default is 2.0s.
</p>
</div>
<div className="pt-4 border-t border-helios-line/10">

View File

@@ -1,53 +1,80 @@
import { useState, useEffect, useId } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Activity, X, Zap, CheckCircle2, AlertTriangle, Database } from "lucide-react";
import { apiFetch } from "../lib/api";
import { useSharedStats } from "../lib/statsStore";
interface Stats {
active: number;
concurrent_limit: number;
completed: number;
failed: number;
total: number;
function focusables(root: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
return Array.from(root.querySelectorAll<HTMLElement>(selector));
}
export default function SystemStatus() {
const [stats, setStats] = useState<Stats | null>(null);
const { stats, error } = useSharedStats();
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState<string | null>(null);
const layoutId = useId();
const modalRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const lastFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
const res = await apiFetch("/api/stats");
if (res.ok) {
const data = await res.json();
setStats({
active: data.active || 0,
concurrent_limit: data.concurrent_limit || 1,
completed: data.completed || 0,
failed: data.failed || 0,
total: data.total || 0,
});
setError(null);
} else {
setError("Status unavailable");
}
} catch (e) {
console.error("Failed to fetch system status", e);
setError("Status unavailable");
if (!isExpanded) {
return;
}
lastFocusedRef.current = document.activeElement as HTMLElement | null;
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setIsExpanded(false);
return;
}
if (event.key !== "Tab") {
return;
}
const root = modalRef.current;
if (!root) {
return;
}
const list = focusables(root);
if (list.length === 0) {
return;
}
const first = list[0];
const last = list[list.length - 1];
const current = document.activeElement as HTMLElement | null;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
void fetchStats();
const interval = setInterval(fetchStats, 5000);
return () => clearInterval(interval);
}, []);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
lastFocusedRef.current?.focus();
};
}, [isExpanded]);
if (!stats) {
return (
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-2" aria-live="polite">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">
{error ? "Status Unavailable" : "Loading Status..."}
</span>
@@ -62,7 +89,6 @@ export default function SystemStatus() {
return (
<>
{/* Compact Sidebar View */}
<motion.div
layoutId={layoutId}
onClick={() => setIsExpanded(true)}
@@ -73,13 +99,13 @@ export default function SystemStatus() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? "bg-status-success" : "bg-helios-slate"}`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? "bg-status-success" : "bg-helios-slate"}`}></span>
</span>
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider group-hover:text-helios-inc transition-colors">Engine Status</span>
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider group-hover:text-helios-ink transition-colors">Engine Status</span>
</div>
<div className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? 'bg-status-success/10 text-status-success' : 'bg-helios-slate/10 text-helios-slate'}`}>
{isActive ? 'ONLINE' : 'IDLE'}
<div className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md ${isActive ? "bg-status-success/10 text-status-success" : "bg-helios-slate/10 text-helios-slate"}`}>
{isActive ? "ONLINE" : "IDLE"}
</div>
</div>
@@ -87,18 +113,16 @@ export default function SystemStatus() {
<div className="flex items-end justify-between text-helios-ink">
<span className="text-xs font-medium opacity-80">Active Jobs</span>
<div className="flex items-baseline gap-0.5">
<span className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
<span className={`text-lg font-bold ${isFull ? "text-status-warning" : "text-helios-solar"}`}>
{stats.active}
</span>
<span className="text-xs text-helios-slate">
/ {stats.concurrent_limit}
</span>
<span className="text-xs text-helios-slate">/ {stats.concurrent_limit}</span>
</div>
</div>
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
<div
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? "bg-status-warning" : "bg-helios-solar"}`}
style={{ width: `${percentage}%` }}
/>
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
@@ -108,11 +132,9 @@ export default function SystemStatus() {
</div>
</motion.div>
{/* Expanded Modal View */}
<AnimatePresence>
{isExpanded && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -120,13 +142,16 @@ export default function SystemStatus() {
onClick={() => setIsExpanded(false)}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4"
>
{/* Modal Card */}
<motion.div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="system-status-title"
layoutId={layoutId}
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative outline-none"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header Background Effect */}
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-helios-solar/10 to-transparent pointer-events-none" />
<div className="p-8 relative">
@@ -136,42 +161,38 @@ export default function SystemStatus() {
<Activity className="text-helios-solar" size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-helios-ink tracking-tight">System Status</h2>
<h2 id="system-status-title" className="text-xl font-bold text-helios-ink tracking-tight">System Status</h2>
<div className="flex items-center gap-2 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? "bg-status-success" : "bg-helios-slate"}`}></span>
<span className="text-xs font-medium text-helios-slate uppercase tracking-wide">
{isActive ? 'Engine Running' : 'Engine Idle'}
{isActive ? "Engine Running" : "Engine Idle"}
</span>
</div>
</div>
</div>
<button
ref={closeRef}
onClick={() => setIsExpanded(false)}
className="p-2 hover:bg-helios-surface-soft rounded-full text-helios-slate hover:text-helios-ink transition-colors"
aria-label="Close system status dialog"
>
<X size={20} />
</button>
</div>
{/* Main Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-helios-surface-soft/50 rounded-2xl p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
<Zap size={20} className="text-helios-solar opacity-80" />
<div className="flex flex-col">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Concurrency</span>
<div className="flex items-baseline justify-center gap-1 mt-1">
<span className="text-3xl font-bold text-helios-ink">
{stats.active}
</span>
<span className="text-sm font-medium text-helios-slate opacity-60">
/ {stats.concurrent_limit}
</span>
<span className="text-3xl font-bold text-helios-ink">{stats.active}</span>
<span className="text-sm font-medium text-helios-slate opacity-60">/ {stats.concurrent_limit}</span>
</div>
</div>
{/* Big Progress Bar */}
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
<div
className={`h-full rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
className={`h-full rounded-full ${isFull ? "bg-status-warning" : "bg-helios-solar"}`}
style={{ width: `${percentage}%` }}
/>
</div>
@@ -181,17 +202,12 @@ export default function SystemStatus() {
<Database size={20} className="text-blue-400 opacity-80" />
<div className="flex flex-col">
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Total Jobs</span>
<span className="text-3xl font-bold text-helios-ink mt-1">
{stats.total}
</span>
</div>
<div className="text-[10px] text-helios-slate mt-1 px-2 py-0.5 bg-helios-line/10 rounded-md">
Lifetime
<span className="text-3xl font-bold text-helios-ink mt-1">{stats.total}</span>
</div>
<div className="text-[10px] text-helios-slate mt-1 px-2 py-0.5 bg-helios-line/10 rounded-md">Lifetime</div>
</div>
</div>
{/* Secondary Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="p-3 rounded-xl bg-status-success/5 border border-status-success/10 flex flex-col items-center justify-center text-center">
<CheckCircle2 size={16} className="text-status-success mb-1" />
@@ -213,9 +229,7 @@ export default function SystemStatus() {
</div>
<div className="mt-6 pt-6 border-t border-helios-line/10 text-center">
<p className="text-xs text-helios-slate/60">
System metrics update automatically every 5 seconds.
</p>
<p className="text-xs text-helios-slate/60">System metrics update automatically while this tab is active.</p>
</div>
</div>
</motion.div>

View File

@@ -10,7 +10,8 @@ import {
} from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -44,13 +45,11 @@ export default function TranscodeSettings() {
const fetchSettings = async () => {
try {
const res = await apiFetch("/api/settings/transcode");
if (!res.ok) throw new Error("Failed to load settings");
const data = await res.json();
const data = await apiJson<TranscodeSettingsPayload>("/api/settings/transcode");
setSettings(data);
setError("");
} catch (err) {
setError("Unable to load current settings.");
console.error(err);
setError(isApiError(err) ? err.message : "Unable to load current settings.");
} finally {
setLoading(false);
}
@@ -63,15 +62,17 @@ export default function TranscodeSettings() {
setSuccess(false);
try {
const res = await apiFetch("/api/settings/transcode", {
await apiAction("/api/settings/transcode", {
method: "POST",
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error("Failed to save settings");
setSuccess(true);
showToast({ kind: "success", title: "Transcoding", message: "Transcode settings saved." });
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError("Failed to save settings.");
const message = isApiError(err) ? err.message : "Failed to save settings.";
setError(message);
showToast({ kind: "error", title: "Transcoding", message });
} finally {
setSaving(false);
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react";
import { FolderOpen, Trash2, Plus, Folder, Play } from "lucide-react";
import { apiFetch } from "../lib/api";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
import ConfirmDialog from "./ui/ConfirmDialog";
interface WatchDir {
id: number;
@@ -13,16 +15,17 @@ export default function WatchFolders() {
const [path, setPath] = useState("");
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRemoveId, setPendingRemoveId] = useState<number | null>(null);
const fetchDirs = async () => {
try {
const res = await apiFetch("/api/settings/watch-dirs");
if (res.ok) {
const data = await res.json();
setDirs(data);
}
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
setDirs(data);
setError(null);
} catch (e) {
console.error("Failed to fetch watch dirs", e);
const message = isApiError(e) ? e.message : "Failed to fetch watch directories";
setError(message);
} finally {
setLoading(false);
}
@@ -34,12 +37,16 @@ export default function WatchFolders() {
const triggerScan = async () => {
setScanning(true);
setError(null);
try {
await apiFetch("/api/scan/start", { method: "POST" });
await apiAction("/api/scan/start", { method: "POST" });
showToast({ kind: "success", title: "Scan", message: "Library scan started." });
} catch (e) {
console.error("Failed to start scan", e);
const message = isApiError(e) ? e.message : "Failed to start scan";
setError(message);
showToast({ kind: "error", title: "Scan", message });
} finally {
setTimeout(() => setScanning(false), 2000);
window.setTimeout(() => setScanning(false), 1200);
}
};
@@ -48,39 +55,40 @@ export default function WatchFolders() {
if (!path.trim()) return;
try {
const res = await apiFetch("/api/settings/watch-dirs", {
await apiAction("/api/settings/watch-dirs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: path.trim(), is_recursive: true })
body: JSON.stringify({ path: path.trim(), is_recursive: true }),
});
if (res.ok) {
setPath("");
await fetchDirs();
}
setPath("");
setError(null);
await fetchDirs();
showToast({ kind: "success", title: "Watch Folders", message: "Folder added." });
} catch (e) {
console.error("Failed to add directory", e);
const message = isApiError(e) ? e.message : "Failed to add directory";
setError(message);
showToast({ kind: "error", title: "Watch Folders", message });
}
};
const removeDir = async (id: number) => {
if (!confirm("Stop watching this folder?")) return;
try {
const res = await apiFetch(`/api/settings/watch-dirs/${id}`, {
method: "DELETE"
await apiAction(`/api/settings/watch-dirs/${id}`, {
method: "DELETE",
});
if (res.ok) {
await fetchDirs();
}
setError(null);
await fetchDirs();
showToast({ kind: "success", title: "Watch Folders", message: "Folder removed." });
} catch (e) {
console.error("Failed to remove directory", e);
const message = isApiError(e) ? e.message : "Failed to remove directory";
setError(message);
showToast({ kind: "error", title: "Watch Folders", message });
}
};
return (
<div className="space-y-6">
<div className="space-y-6" aria-live="polite">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-helios-solar/10 rounded-lg">
@@ -92,7 +100,7 @@ export default function WatchFolders() {
</div>
</div>
<button
onClick={triggerScan}
onClick={() => void triggerScan()}
disabled={scanning}
className="flex items-center gap-2 px-3 py-1.5 bg-helios-solar/10 hover:bg-helios-solar/20 text-helios-solar rounded-lg text-xs font-bold uppercase tracking-wider transition-colors disabled:opacity-50"
>
@@ -101,6 +109,12 @@ export default function WatchFolders() {
</button>
</div>
{error && (
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
{error}
</div>
)}
<form onSubmit={addDir} className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
@@ -133,7 +147,7 @@ export default function WatchFolders() {
</span>
</div>
<button
onClick={() => removeDir(dir.id)}
onClick={() => setPendingRemoveId(dir.id)}
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all opacity-0 group-hover:opacity-100"
title="Stop watching"
>
@@ -156,6 +170,19 @@ export default function WatchFolders() {
</div>
)}
</div>
<ConfirmDialog
open={pendingRemoveId !== null}
title="Stop watching folder"
description="Stop watching this folder for new media?"
confirmLabel="Stop Watching"
tone="danger"
onClose={() => setPendingRemoveId(null)}
onConfirm={async () => {
if (pendingRemoveId === null) return;
await removeDir(pendingRemoveId);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from "react";
interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: "primary" | "danger";
onConfirm: () => Promise<void> | void;
onClose: () => void;
}
function focusableElements(root: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(element) => !element.hasAttribute("disabled")
);
}
export default function ConfirmDialog({
open,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
tone = "primary",
onConfirm,
onClose,
}: ConfirmDialogProps) {
const [submitting, setSubmitting] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const lastFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
lastFocusedRef.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
if (panel) {
const focusables = focusableElements(panel);
if (focusables.length > 0) {
focusables[0].focus();
} else {
panel.focus();
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (!open) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (!submitting) {
onClose();
}
return;
}
if (event.key !== "Tab") {
return;
}
const root = panelRef.current;
if (!root) {
return;
}
const focusables = focusableElements(root);
if (focusables.length === 0) {
event.preventDefault();
root.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = document.activeElement as HTMLElement | null;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
if (lastFocusedRef.current) {
lastFocusedRef.current.focus();
}
};
}, [open, onClose, submitting]);
useEffect(() => {
if (!open) {
setSubmitting(false);
}
}, [open]);
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[200]">
<button
type="button"
aria-label="Close dialog"
onClick={() => !submitting && onClose()}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<div className="absolute inset-0 flex items-center justify-center px-4">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
tabIndex={-1}
className="w-full max-w-sm rounded-2xl border border-helios-line/30 bg-helios-surface p-6 shadow-2xl outline-none"
>
<h3 id="confirm-dialog-title" className="text-lg font-bold text-helios-ink">
{title}
</h3>
<p id="confirm-dialog-description" className="mt-2 text-sm text-helios-slate">
{description}
</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-semibold text-helios-slate hover:bg-helios-surface-soft"
>
{cancelLabel}
</button>
<button
type="button"
disabled={submitting}
onClick={async () => {
setSubmitting(true);
try {
await onConfirm();
onClose();
} finally {
setSubmitting(false);
}
}}
className={
tone === "danger"
? "rounded-lg bg-status-error/20 px-4 py-2 text-sm font-semibold text-status-error hover:bg-status-error/30"
: "rounded-lg bg-helios-solar px-4 py-2 text-sm font-semibold text-helios-main hover:brightness-110"
}
>
{submitting ? "Working..." : confirmLabel}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, CheckCircle2, Info, X, type LucideIcon } from "lucide-react";
import { subscribeToToasts, type ToastKind, type ToastMessage } from "../../lib/toast";
const DEFAULT_DURATION_MS = 3500;
const MAX_TOASTS = 4;
function kindStyles(kind: ToastKind): { icon: LucideIcon; className: string } {
if (kind === "success") {
return {
icon: CheckCircle2,
className: "border-status-success/30 bg-status-success/10 text-status-success",
};
}
if (kind === "error") {
return {
icon: AlertCircle,
className: "border-status-error/30 bg-status-error/10 text-status-error",
};
}
return {
icon: Info,
className: "border-helios-line/40 bg-helios-surface text-helios-ink",
};
}
export default function ToastRegion() {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
useEffect(() => {
return subscribeToToasts((message) => {
setToasts((prev) => {
const next = [message, ...prev];
return next.slice(0, MAX_TOASTS);
});
});
}, []);
useEffect(() => {
if (toasts.length === 0) {
return;
}
const timers = toasts.map((toast) =>
window.setTimeout(() => {
setToasts((prev) => prev.filter((item) => item.id !== toast.id));
}, toast.durationMs ?? DEFAULT_DURATION_MS)
);
return () => {
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [toasts]);
const liveMessage = useMemo(() => {
if (toasts.length === 0) {
return "";
}
const top = toasts[0];
return top.title ? `${top.title}: ${top.message}` : top.message;
}, [toasts]);
if (toasts.length === 0) {
return <div className="sr-only" aria-live="polite" aria-atomic="true">{liveMessage}</div>;
}
return (
<>
<div className="sr-only" aria-live="polite" aria-atomic="true">
{liveMessage}
</div>
<div className="fixed top-4 right-4 z-[300] flex w-[min(92vw,360px)] flex-col gap-2 pointer-events-none">
{toasts.map((toast) => {
const { icon: Icon, className } = kindStyles(toast.kind);
return (
<div
key={toast.id}
role={toast.kind === "error" ? "alert" : "status"}
className={`pointer-events-auto rounded-xl border p-3 shadow-xl ${className}`}
>
<div className="flex items-start gap-2">
<Icon size={16} />
<div className="min-w-0 flex-1">
{toast.title && (
<p className="text-xs font-bold uppercase tracking-wide">{toast.title}</p>
)}
<p className="text-sm break-words">{toast.message}</p>
</div>
<button
type="button"
className="rounded p-1 hover:bg-black/10"
aria-label="Dismiss notification"
onClick={() =>
setToasts((prev) => prev.filter((item) => item.id !== toast.id))
}
>
<X size={14} />
</button>
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -1,6 +1,8 @@
---
import "../styles/global.css";
import { ViewTransitions } from "astro:transitions";
import ToastRegion from "../components/ui/ToastRegion";
import AuthGuard from "../components/AuthGuard";
interface Props {
title: string;
@@ -22,6 +24,8 @@ const { title } = Astro.props;
</head>
<body>
<slot />
<ToastRegion client:load />
<AuthGuard client:load />
<script is:inline>
function initTheme() {
try {
@@ -36,40 +40,12 @@ const { title } = Astro.props;
}
}
function checkAuth() {
const isAuthPage = window.location.pathname.startsWith('/login') || window.location.pathname.startsWith('/setup');
if (isAuthPage) return;
fetch('/api/engine/status', { credentials: 'same-origin' })
.then(res => {
if (res.status !== 401) return;
return fetch('/api/setup/status')
.then(statusRes => {
if (!statusRes.ok) return null;
return statusRes.json();
})
.then(data => {
if (!data) return;
if (data.setup_required) {
window.location.href = '/setup';
} else {
window.location.href = '/login';
}
});
})
.catch(() => {
// Keep user on current page on transient backend/network failures.
});
}
// Run on initial load
initTheme();
checkAuth();
// Run on view transition navigation
document.addEventListener('astro:after-swap', () => {
initTheme();
checkAuth();
});
</script>
</body>

View File

@@ -1,11 +1,92 @@
export interface ApiErrorShape {
status: number;
message: string;
body?: unknown;
url: string;
}
export class ApiError extends Error implements ApiErrorShape {
status: number;
body?: unknown;
url: string;
constructor({ status, message, body, url }: ApiErrorShape) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
this.url = url;
}
}
function bodyMessage(body: unknown): string | null {
if (typeof body === "string") {
const trimmed = body.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (body && typeof body === "object") {
const known = body as { message?: unknown; error?: unknown; detail?: unknown };
if (typeof known.message === "string" && known.message.trim().length > 0) {
return known.message;
}
if (typeof known.error === "string" && known.error.trim().length > 0) {
return known.error;
}
if (typeof known.detail === "string" && known.detail.trim().length > 0) {
return known.detail;
}
}
return null;
}
async function parseResponseBody(response: Response): Promise<unknown> {
if (response.status === 204 || response.status === 205) {
return undefined;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json();
}
const text = await response.text();
if (text.length === 0) {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function toApiError(url: string, response: Response): Promise<ApiError> {
const body = await parseResponseBody(response).catch(() => undefined);
const message =
bodyMessage(body) ??
response.statusText ??
`Request failed with status ${response.status}`;
return new ApiError({
status: response.status,
message,
body,
url,
});
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
/**
* Authenticated fetch utility using cookie auth.
*/
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
const headers = new Headers(options.headers);
if (!headers.has('Content-Type') && typeof options.body === 'string') {
headers.set('Content-Type', 'application/json');
if (!headers.has("Content-Type") && typeof options.body === "string") {
headers.set("Content-Type", "application/json");
}
const controller = new AbortController();
@@ -16,7 +97,7 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
if (options.signal.aborted) {
controller.abort();
} else {
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
}
@@ -24,17 +105,15 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
const response = await fetch(url, {
...options,
headers,
credentials: options.credentials ?? 'same-origin',
credentials: options.credentials ?? "same-origin",
signal: controller.signal,
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
const path = window.location.pathname;
const isAuthPage = path.startsWith('/login') || path.startsWith('/setup');
if (!isAuthPage) {
window.location.href = '/login';
}
if (response.status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
if (!isAuthPage) {
window.location.href = "/login";
}
}
@@ -44,19 +123,34 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
}
}
/**
* Helper for GET requests
*/
export async function apiGet(url: string): Promise<Response> {
return apiFetch(url);
export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await apiFetch(url, options);
if (!response.ok) {
throw await toApiError(url, response);
}
return (await parseResponseBody(response)) as T;
}
export async function apiAction(url: string, options: RequestInit = {}): Promise<void> {
const response = await apiFetch(url, options);
if (!response.ok) {
throw await toApiError(url, response);
}
}
/**
* Helper for POST requests with JSON body
* Helper for GET JSON requests.
*/
export async function apiPost(url: string, body?: unknown): Promise<Response> {
return apiFetch(url, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined
export async function apiGet<T>(url: string): Promise<T> {
return apiJson<T>(url);
}
/**
* Helper for POST JSON requests.
*/
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
return apiJson<T>(url, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
}

151
web/src/lib/statsStore.ts Normal file
View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import { apiJson, isApiError } from "./api";
export interface SharedStats {
active: number;
concurrent_limit: number;
completed: number;
failed: number;
total: number;
}
export interface SharedStatsSnapshot {
stats: SharedStats | null;
loading: boolean;
error: string | null;
lastUpdatedAt: number | null;
}
const VISIBLE_INTERVAL_MS = 5000;
const HIDDEN_INTERVAL_MS = 15000;
let snapshot: SharedStatsSnapshot = {
stats: null,
loading: true,
error: null,
lastUpdatedAt: null,
};
const listeners = new Set<(value: SharedStatsSnapshot) => void>();
let pollTimer: number | null = null;
let polling = false;
function emit(): void {
for (const listener of listeners) {
listener(snapshot);
}
}
function currentIntervalMs(): number {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return HIDDEN_INTERVAL_MS;
}
return VISIBLE_INTERVAL_MS;
}
function scheduleNextPoll(): void {
if (!polling || typeof window === "undefined") {
return;
}
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
}
pollTimer = window.setTimeout(() => {
void pollNow();
}, currentIntervalMs());
}
function normalizeStats(input: Partial<SharedStats>): SharedStats {
return {
active: Number(input.active ?? 0),
concurrent_limit: Math.max(1, Number(input.concurrent_limit ?? 1)),
completed: Number(input.completed ?? 0),
failed: Number(input.failed ?? 0),
total: Number(input.total ?? 0),
};
}
async function pollNow(): Promise<void> {
try {
const data = await apiJson<Partial<SharedStats>>("/api/stats");
snapshot = {
stats: normalizeStats(data),
loading: false,
error: null,
lastUpdatedAt: Date.now(),
};
} catch (error) {
snapshot = {
...snapshot,
loading: false,
error: isApiError(error) ? error.message : "Status unavailable",
};
} finally {
emit();
scheduleNextPoll();
}
}
function onVisibilityChange(): void {
if (!polling) {
return;
}
if (typeof document !== "undefined" && document.visibilityState === "visible") {
if (pollTimer !== null && typeof window !== "undefined") {
window.clearTimeout(pollTimer);
pollTimer = null;
}
void pollNow();
return;
}
scheduleNextPoll();
}
function startPolling(): void {
if (polling || typeof window === "undefined") {
return;
}
polling = true;
document.addEventListener("visibilitychange", onVisibilityChange);
void pollNow();
}
function stopPolling(): void {
if (!polling) {
return;
}
polling = false;
document.removeEventListener("visibilitychange", onVisibilityChange);
if (pollTimer !== null && typeof window !== "undefined") {
window.clearTimeout(pollTimer);
pollTimer = null;
}
}
function subscribe(listener: (value: SharedStatsSnapshot) => void): () => void {
listeners.add(listener);
listener(snapshot);
if (listeners.size === 1) {
startPolling();
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
stopPolling();
}
};
}
export function useSharedStats(): SharedStatsSnapshot {
const [value, setValue] = useState<SharedStatsSnapshot>(snapshot);
useEffect(() => subscribe(setValue), []);
return value;
}

52
web/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,52 @@
export type ToastKind = "success" | "error" | "info";
export interface ToastInput {
kind: ToastKind;
message: string;
title?: string;
durationMs?: number;
}
export interface ToastMessage extends ToastInput {
id: string;
}
const TOAST_EVENT = "alchemist:toast";
function nextToastId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function showToast(input: ToastInput): void {
if (typeof window === "undefined") {
return;
}
const payload: ToastMessage = {
...input,
id: nextToastId(),
};
window.dispatchEvent(
new CustomEvent<ToastMessage>(TOAST_EVENT, {
detail: payload,
})
);
}
export function subscribeToToasts(callback: (message: ToastMessage) => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
}
const handler = (event: Event) => {
const customEvent = event as CustomEvent<ToastMessage>;
if (!customEvent.detail) {
return;
}
callback(customEvent.detail);
};
window.addEventListener(TOAST_EVENT, handler as EventListener);
return () => window.removeEventListener(TOAST_EVENT, handler as EventListener);
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timeoutId = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timeoutId);
}, [value, delayMs]);
return debounced;
}

View File

@@ -61,8 +61,9 @@ import { ArrowRight } from "lucide-react";
</Layout>
<script>
fetch("/api/setup/status")
.then((res) => res.json())
import { apiAction, apiJson, isApiError } from "../lib/api";
void apiJson<{ setup_required: boolean }>("/api/setup/status")
.then((data) => {
if (data?.setup_required) {
window.location.href = "/setup";
@@ -82,23 +83,22 @@ import { ArrowRight } from "lucide-react";
errorMsg?.classList.add('hidden');
try {
const res = await fetch('/api/auth/login', {
await apiAction('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
});
if (res.ok) {
window.location.href = '/';
} else {
errorMsg?.classList.remove('hidden');
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Invalid username or password";
}
window.location.href = '/';
} catch (err) {
console.error(err);
errorMsg?.classList.remove('hidden');
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Connection failed";
console.error("Login failed", err);
errorMsg?.classList.remove('hidden');
const message = isApiError(err)
? err.status === 401
? "Invalid username or password"
: err.message
: "Connection failed";
if (errorMsg) errorMsg.querySelector('span')!.textContent = message;
}
});
</script>

View File

@@ -14,9 +14,10 @@ import SetupWizard from "../components/SetupWizard";
</Layout>
<script>
import { apiJson } from "../lib/api";
// If setup is already done, redirect to dashboard
fetch("/api/setup/status")
.then((res) => res.json())
apiJson<{ setup_required: boolean }>("/api/setup/status")
.then((data) => {
if (!data.setup_required) {
window.location.href = "/";