mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ alchemist.db
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/.astro
|
||||
web/package-lock.json
|
||||
web.zip
|
||||
.DS_Store
|
||||
alchemist.db-shm
|
||||
|
||||
@@ -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
4
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
5
Dockerfile
vendored
@@ -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"]
|
||||
|
||||
11
README.md
11
README.md
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -85,3 +85,8 @@ config:
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
runtime:
|
||||
configPath: /app/config/config.toml
|
||||
dbPath: /app/data/alchemist.db
|
||||
configMutable: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
47
src/db.rs
47
src/db.rs
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
385
src/main.rs
385
src/main.rs
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
39
src/runtime.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
1099
src/server.rs
1099
src/server.rs
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
4
web-e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.runtime/
|
||||
21
web-e2e/bun.lock
generated
Normal file
21
web-e2e/bun.lock
generated
Normal 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
81
web-e2e/global-setup.ts
Normal 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
16
web-e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50
web-e2e/playwright.config.ts
Normal file
50
web-e2e/playwright.config.ts
Normal 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
10
web-e2e/testConfig.ts
Normal 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
19
web-e2e/tests/helpers.ts
Normal 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();
|
||||
}
|
||||
110
web-e2e/tests/jobs-actions-nonok.spec.ts
Normal file
110
web-e2e/tests/jobs-actions-nonok.spec.ts
Normal 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");
|
||||
});
|
||||
246
web-e2e/tests/settings-nonok.spec.ts
Normal file
246
web-e2e/tests/settings-nonok.spec.ts
Normal 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);
|
||||
});
|
||||
68
web-e2e/tests/setup-recovery.spec.ts
Normal file
68
web-e2e/tests/setup-recovery.spec.ts
Normal 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);
|
||||
});
|
||||
70
web-e2e/tests/stats-poller.spec.ts
Normal file
70
web-e2e/tests/stats-poller.spec.ts
Normal 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
178
web/bun.lock
generated
@@ -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
4632
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
50
web/src/components/AuthGuard.tsx
Normal file
50
web/src/components/AuthGuard.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
179
web/src/components/ui/ConfirmDialog.tsx
Normal file
179
web/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
web/src/components/ui/ToastRegion.tsx
Normal file
108
web/src/components/ui/ToastRegion.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
151
web/src/lib/statsStore.ts
Normal 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
52
web/src/lib/toast.ts
Normal 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);
|
||||
}
|
||||
12
web/src/lib/useDebouncedValue.ts
Normal file
12
web/src/lib/useDebouncedValue.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = "/";
|
||||
|
||||
Reference in New Issue
Block a user