mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -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/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
web/.astro
|
web/.astro
|
||||||
|
web/package-lock.json
|
||||||
web.zip
|
web.zip
|
||||||
.DS_Store
|
.DS_Store
|
||||||
alchemist.db-shm
|
alchemist.db-shm
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.2.8] - 2026-01-12
|
||||||
- Setup wizard auth fixes, scheduler time validation, and watcher reliability improvements.
|
- Setup wizard auth fixes, scheduler time validation, and watcher reliability improvements.
|
||||||
- DB stability pass (WAL, FK enforcement, indexes, session cleanup, legacy watch_dirs compatibility).
|
- 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]]
|
[[package]]
|
||||||
name = "alchemist"
|
name = "alchemist"
|
||||||
version = "0.2.8"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
|
"http-body-util",
|
||||||
"inquire",
|
"inquire",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -52,6 +53,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alchemist"
|
name = "alchemist"
|
||||||
version = "0.2.8"
|
version = "0.2.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
@@ -45,3 +45,7 @@ rand = "0.8"
|
|||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
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
|
# Stage 1: Build Frontend with Bun
|
||||||
FROM oven/bun:1 AS frontend-builder
|
FROM oven/bun:1 AS frontend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/package.json web/bun.lockb* ./
|
COPY web/package.json web/bun.lock* ./
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
COPY web/ .
|
COPY web/ .
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
@@ -30,6 +30,7 @@ RUN cargo build --release
|
|||||||
# Stage 4: Runtime
|
# Stage 4: Runtime
|
||||||
FROM debian:testing-slim AS runtime
|
FROM debian:testing-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /app/config /app/data
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@@ -66,6 +67,8 @@ COPY --from=builder /app/target/release/alchemist /usr/local/bin/alchemist
|
|||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV LIBVA_DRIVER_NAME=iHD
|
ENV LIBVA_DRIVER_NAME=iHD
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
|
ENV ALCHEMIST_CONFIG_PATH=/app/config/config.toml
|
||||||
|
ENV ALCHEMIST_DB_PATH=/app/data/alchemist.db
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT ["alchemist"]
|
ENTRYPOINT ["alchemist"]
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -46,8 +46,13 @@ docker build -t alchemist .
|
|||||||
# Run the container
|
# Run the container
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
|
-v /path/to/config.toml:/app/config/config.toml:ro \
|
||||||
-v /path/to/media:/media \
|
-v /path/to/media:/media \
|
||||||
-v /path/to/output:/output \
|
-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 \
|
--name alchemist \
|
||||||
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
|
## Supported Platforms
|
||||||
|
|
||||||
- **Linux**: x86_64 (Docker & Binary)
|
- **Linux**: x86_64 (Docker & Binary)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ spec:
|
|||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /app/config.toml
|
mountPath: {{ .Values.runtime.configPath | quote }}
|
||||||
subPath: config.toml
|
subPath: config.toml
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
@@ -62,6 +62,12 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: RUST_LOG
|
- name: RUST_LOG
|
||||||
value: "info"
|
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:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
configMap:
|
configMap:
|
||||||
|
|||||||
@@ -85,3 +85,8 @@ config:
|
|||||||
nodeSelector: {}
|
nodeSelector: {}
|
||||||
tolerations: []
|
tolerations: []
|
||||||
affinity: {}
|
affinity: {}
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
configPath: /app/config/config.toml
|
||||||
|
dbPath: /app/data/alchemist.db
|
||||||
|
configMutable: false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
# Configuration file
|
||||||
- ./config.toml:/app/config.toml:ro
|
- ./config.toml:/app/config/config.toml:ro
|
||||||
# Media directories (adjust paths as needed)
|
# Media directories (adjust paths as needed)
|
||||||
- /path/to/media:/media
|
- /path/to/media:/media
|
||||||
- /path/to/output:/output
|
- /path/to/output:/output
|
||||||
@@ -16,6 +16,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
- TZ=America/New_York
|
- 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)
|
# For Intel QuickSync (uncomment if needed)
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/dri:/dev/dri
|
# - /dev/dri:/dev/dri
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ On first launch, Alchemist runs an interactive setup wizard to:
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
### Full Configuration Reference
|
||||||
|
|
||||||
@@ -291,9 +293,11 @@ host = "0.0.0.0"
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `ALCHEMIST_CONFIG` | Path to config file | `./config.toml` |
|
| `ALCHEMIST_CONFIG_PATH` | Primary config file path | `./config.toml` |
|
||||||
| `ALCHEMIST_DATA_DIR` | Data directory path | `./data` |
|
| `ALCHEMIST_CONFIG` | Legacy alias for config path | unset |
|
||||||
| `ALCHEMIST_LOG_LEVEL` | Log verbosity | `info` |
|
| `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: $_"
|
Write-Error "Failed to download or install FFmpeg: $_"
|
||||||
} finally {
|
} finally {
|
||||||
if (Test-Path $tempDir) {
|
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,
|
input_path: &Path,
|
||||||
output_path: &Path,
|
output_path: &Path,
|
||||||
mtime: std::time::SystemTime,
|
mtime: std::time::SystemTime,
|
||||||
) -> Result<()> {
|
) -> Result<bool> {
|
||||||
if input_path == output_path {
|
if input_path == output_path {
|
||||||
return Err(crate::error::AlchemistError::Config(
|
return Err(crate::error::AlchemistError::Config(
|
||||||
"Output path matches input path".into(),
|
"Output path matches input path".into(),
|
||||||
@@ -390,7 +390,7 @@ impl Db {
|
|||||||
Err(_) => "0.0".to_string(), // Fallback for very old files/clocks
|
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)
|
"INSERT INTO jobs (input_path, output_path, status, mtime_hash, updated_at)
|
||||||
VALUES (?, ?, 'queued', ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, 'queued', ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(input_path) DO UPDATE SET
|
ON CONFLICT(input_path) DO UPDATE SET
|
||||||
@@ -406,7 +406,7 @@ impl Db {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_job(&self, job: Job) -> Result<()> {
|
pub async fn add_job(&self, job: Job) -> Result<()> {
|
||||||
@@ -828,6 +828,15 @@ impl Db {
|
|||||||
Ok(job)
|
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> {
|
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_is_recursive = self.has_column("watch_dirs", "is_recursive").await?;
|
||||||
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
|
let has_recursive = self.has_column("watch_dirs", "recursive").await?;
|
||||||
@@ -1383,6 +1392,32 @@ mod tests {
|
|||||||
assert!(settings.should_replace_existing_output());
|
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]
|
#[tokio::test]
|
||||||
async fn test_claim_next_job_marks_analyzing(
|
async fn test_claim_next_job_marks_analyzing(
|
||||||
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -1394,12 +1429,14 @@ mod tests {
|
|||||||
|
|
||||||
let input1 = Path::new("input1.mkv");
|
let input1 = Path::new("input1.mkv");
|
||||||
let output1 = Path::new("output1.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?;
|
.await?;
|
||||||
|
|
||||||
let input2 = Path::new("input2.mkv");
|
let input2 = Path::new("input2.mkv");
|
||||||
let output2 = Path::new("output2.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?;
|
.await?;
|
||||||
|
|
||||||
let first = db
|
let first = db
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod error;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
pub mod runtime;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
|||||||
385
src/main.rs
385
src/main.rs
@@ -1,9 +1,9 @@
|
|||||||
use alchemist::error::Result;
|
use alchemist::error::Result;
|
||||||
use alchemist::system::hardware;
|
use alchemist::system::hardware;
|
||||||
use alchemist::{config, db, Agent, Transcoder};
|
use alchemist::{config, db, runtime, Agent, Transcoder};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
@@ -28,9 +28,6 @@ struct Args {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
dry_run: bool,
|
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)
|
/// Reset admin user/password and sessions (forces setup mode)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
reset_auth: bool,
|
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<()> {
|
async fn run() -> Result<()> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
@@ -102,11 +178,10 @@ async fn run() -> Result<()> {
|
|||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
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.cli,
|
||||||
args.reset_auth,
|
args.reset_auth,
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
args.output_dir,
|
|
||||||
args.directories.len()
|
args.directories.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,13 +195,16 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
// 0. Load Configuration
|
// 0. Load Configuration
|
||||||
let config_start = Instant::now();
|
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_exists = config_path.exists();
|
||||||
let (config, mut setup_mode) = if !config_exists {
|
let (config, mut setup_mode) = if !config_exists {
|
||||||
let cwd = std::env::current_dir().ok();
|
let cwd = std::env::current_dir().ok();
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"config.toml not found (cwd={:?})",
|
"Config file not found at {:?} (cwd={:?})",
|
||||||
|
config_path,
|
||||||
cwd
|
cwd
|
||||||
);
|
);
|
||||||
if is_server_mode {
|
if is_server_mode {
|
||||||
@@ -138,10 +216,13 @@ async fn run() -> Result<()> {
|
|||||||
(config::Config::default(), false)
|
(config::Config::default(), false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match config::Config::load(config_path) {
|
match config::Config::load(config_path.as_path()) {
|
||||||
Ok(c) => (c, false),
|
Ok(c) => (c, false),
|
||||||
Err(e) => {
|
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 {
|
if is_server_mode {
|
||||||
warn!("Config load failed in server mode. Entering Setup Mode (Web UI).");
|
warn!("Config load failed in server mode. Entering Setup Mode (Web UI).");
|
||||||
(config::Config::default(), true)
|
(config::Config::default(), true)
|
||||||
@@ -153,18 +234,26 @@ async fn run() -> Result<()> {
|
|||||||
};
|
};
|
||||||
info!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"Config loaded (exists={} setup_mode={}) in {} ms",
|
"Config loaded (path={:?}, exists={}, mutable={}, setup_mode={}) in {} ms",
|
||||||
|
config_path,
|
||||||
config_exists,
|
config_exists,
|
||||||
|
config_mutable,
|
||||||
setup_mode,
|
setup_mode,
|
||||||
config_start.elapsed().as_millis()
|
config_start.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Initialize Database
|
// 1. Initialize Database
|
||||||
let db_start = Instant::now();
|
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!(
|
info!(
|
||||||
target: "startup",
|
target: "startup",
|
||||||
"Database initialized in {} ms",
|
"Database initialized at {:?} in {} ms",
|
||||||
|
db_path,
|
||||||
db_start.elapsed().as_millis()
|
db_start.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
if args.reset_auth {
|
if args.reset_auth {
|
||||||
@@ -247,7 +336,10 @@ async fn run() -> Result<()> {
|
|||||||
if !config.hardware.allow_cpu_encoding {
|
if !config.hardware.allow_cpu_encoding {
|
||||||
// In setup mode, we might not have set this yet, so don't error out.
|
// In setup mode, we might not have set this yet, so don't error out.
|
||||||
error!("CPU encoding is disabled in configuration.");
|
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(
|
return Err(alchemist::error::AlchemistError::Config(
|
||||||
"CPU encoding disabled".into(),
|
"CPU encoding disabled".into(),
|
||||||
));
|
));
|
||||||
@@ -267,13 +359,14 @@ async fn run() -> Result<()> {
|
|||||||
notification_manager.start_listener(tx.subscribe());
|
notification_manager.start_listener(tx.subscribe());
|
||||||
|
|
||||||
let transcoder = Arc::new(Transcoder::new());
|
let transcoder = Arc::new(Transcoder::new());
|
||||||
|
let hardware_state = hardware::HardwareState::new(Some(hw_info.clone()));
|
||||||
let config = Arc::new(RwLock::new(config));
|
let config = Arc::new(RwLock::new(config));
|
||||||
let agent = Arc::new(
|
let agent = Arc::new(
|
||||||
Agent::new(
|
Agent::new(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
transcoder.clone(),
|
transcoder.clone(),
|
||||||
config.clone(),
|
config.clone(),
|
||||||
Some(hw_info),
|
hardware_state.clone(),
|
||||||
tx.clone(),
|
tx.clone(),
|
||||||
args.dry_run,
|
args.dry_run,
|
||||||
)
|
)
|
||||||
@@ -302,52 +395,16 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
// Start Log Persistence Task
|
// Start Log Persistence Task
|
||||||
let log_db = db.clone();
|
let log_db = db.clone();
|
||||||
let mut log_rx = tx.subscribe();
|
let log_rx = tx.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(event) = log_rx.recv().await {
|
persist_log_events(log_db, log_rx).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 progress so the UI can render accurate job updates.
|
// Persist progress so the UI can render accurate job updates.
|
||||||
let progress_db = db.clone();
|
let progress_db = db.clone();
|
||||||
let mut progress_rx = tx.subscribe();
|
let progress_rx = tx.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut last: HashMap<i64, (f64, Instant)> = HashMap::new();
|
persist_progress_events(progress_db, progress_rx).await;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize File Watcher
|
// Initialize File Watcher
|
||||||
@@ -437,6 +494,8 @@ async fn run() -> Result<()> {
|
|||||||
let config_watcher_arc = config.clone();
|
let config_watcher_arc = config.clone();
|
||||||
let reload_watcher_clone = reload_watcher.clone();
|
let reload_watcher_clone = reload_watcher.clone();
|
||||||
let agent_for_config = agent.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
|
// Channel for file events
|
||||||
let (tx_notify, mut rx_notify) = tokio::sync::mpsc::unbounded_channel();
|
let (tx_notify, mut rx_notify) = tokio::sync::mpsc::unbounded_channel();
|
||||||
@@ -452,11 +511,10 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
match watcher_res {
|
match watcher_res {
|
||||||
Ok(mut watcher) => {
|
Ok(mut watcher) => {
|
||||||
if let Err(e) = watcher.watch(
|
if let Err(e) =
|
||||||
std::path::Path::new("config.toml"),
|
watcher.watch(config_watch_path.as_path(), RecursiveMode::NonRecursive)
|
||||||
RecursiveMode::NonRecursive,
|
{
|
||||||
) {
|
error!("Failed to watch config file {:?}: {}", config_watch_path, e);
|
||||||
error!("Failed to watch config.toml: {}", e);
|
|
||||||
} else {
|
} else {
|
||||||
// Prevent watcher from dropping by keeping it in the spawn if needed,
|
// Prevent watcher from dropping by keeping it in the spawn if needed,
|
||||||
// or just spawning the processing loop.
|
// or just spawning the processing loop.
|
||||||
@@ -472,23 +530,25 @@ async fn run() -> Result<()> {
|
|||||||
info!("Config file changed. Reloading...");
|
info!("Config file changed. Reloading...");
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
match config::Config::load(std::path::Path::new(
|
match apply_reloaded_config(
|
||||||
"config.toml",
|
config_watch_path.as_path(),
|
||||||
)) {
|
&config_watcher_arc,
|
||||||
Ok(new_config) => {
|
&agent_for_config,
|
||||||
let new_limit = new_config.transcode.concurrent_jobs;
|
&hardware_state_for_config,
|
||||||
{
|
)
|
||||||
let mut w = config_watcher_arc.write().await;
|
.await
|
||||||
*w = new_config;
|
{
|
||||||
}
|
Ok(detected_hardware) => {
|
||||||
info!("Configuration reloaded successfully.");
|
info!("Configuration reloaded successfully.");
|
||||||
|
info!(
|
||||||
agent_for_config.set_concurrent_jobs(new_limit).await;
|
"Runtime hardware reloaded: {}",
|
||||||
|
detected_hardware.vendor
|
||||||
// Trigger watcher update (merges DB + New Config)
|
);
|
||||||
reload_watcher_clone(false).await;
|
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,
|
transcoder,
|
||||||
tx,
|
tx,
|
||||||
setup_required: setup_mode,
|
setup_required: setup_mode,
|
||||||
|
config_path: config_path.clone(),
|
||||||
|
config_mutable,
|
||||||
|
hardware_state,
|
||||||
notification_manager: notification_manager.clone(),
|
notification_manager: notification_manager.clone(),
|
||||||
file_watcher,
|
file_watcher,
|
||||||
})
|
})
|
||||||
@@ -517,7 +580,10 @@ async fn run() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
// CLI Mode
|
// CLI Mode
|
||||||
if setup_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
|
// CLI early exit - error
|
||||||
// (Caller will handle pause-on-exit if needed)
|
// (Caller will handle pause-on-exit if needed)
|
||||||
@@ -561,3 +627,170 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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;
|
pub struct Analyzer;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OutputProbe {
|
||||||
|
pub codec_name: String,
|
||||||
|
pub encoder_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Analyzer {
|
impl Analyzer {
|
||||||
/// Async version of probe that doesn't block the runtime
|
/// Async version of probe that doesn't block the runtime
|
||||||
pub async fn probe_async(path: &Path) -> Result<FfprobeMetadata> {
|
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)))?
|
.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 ...
|
// ... should_transcode adapted below ...
|
||||||
|
|
||||||
pub fn should_transcode(
|
pub fn should_transcode(
|
||||||
|
|||||||
@@ -89,11 +89,15 @@ impl Executor for FfmpegExecutor {
|
|||||||
})
|
})
|
||||||
.await?;
|
.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 {
|
Ok(ExecutionResult {
|
||||||
|
requested_encoder: encoder,
|
||||||
used_encoder,
|
used_encoder,
|
||||||
fallback_occurred,
|
fallback_occurred,
|
||||||
|
actual_output_codec: actual_codec,
|
||||||
|
actual_encoder_name,
|
||||||
stats: ExecutionStats {
|
stats: ExecutionStats {
|
||||||
encode_time_secs: 0.0, // Pipeline calculates this
|
encode_time_secs: 0.0, // Pipeline calculates this
|
||||||
input_size: 0,
|
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 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
|
.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)
|
fallback_occurred,
|
||||||
} else {
|
requested,
|
||||||
(true, requested)
|
actual_codec,
|
||||||
}
|
actual_encoder_name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,3 +195,76 @@ fn encoder_codec_name(encoder: Encoder) -> &'static str {
|
|||||||
| Encoder::H264X264 => "h264",
|
| 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::executor::FfmpegExecutor;
|
||||||
use crate::media::planner::{build_hardware_capabilities, BasicPlanner};
|
use crate::media::planner::{build_hardware_capabilities, BasicPlanner};
|
||||||
use crate::orchestrator::Transcoder;
|
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 crate::telemetry::{encoder_label, hardware_label, resolution_bucket, TelemetryEvent};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -158,8 +158,11 @@ pub struct ExecutionPlan {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExecutionResult {
|
pub struct ExecutionResult {
|
||||||
|
pub requested_encoder: Encoder,
|
||||||
pub used_encoder: Encoder,
|
pub used_encoder: Encoder,
|
||||||
pub fallback_occurred: bool,
|
pub fallback_occurred: bool,
|
||||||
|
pub actual_output_codec: Option<crate::config::OutputCodec>,
|
||||||
|
pub actual_encoder_name: Option<String>,
|
||||||
pub stats: ExecutionStats,
|
pub stats: ExecutionStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +204,7 @@ pub struct Pipeline {
|
|||||||
db: Arc<crate::db::Db>,
|
db: Arc<crate::db::Db>,
|
||||||
orchestrator: Arc<Transcoder>,
|
orchestrator: Arc<Transcoder>,
|
||||||
config: Arc<RwLock<crate::config::Config>>,
|
config: Arc<RwLock<crate::config::Config>>,
|
||||||
hw_info: Arc<Option<HardwareInfo>>,
|
hardware_state: HardwareState,
|
||||||
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
|
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
}
|
}
|
||||||
@@ -211,7 +214,7 @@ impl Pipeline {
|
|||||||
db: Arc<crate::db::Db>,
|
db: Arc<crate::db::Db>,
|
||||||
orchestrator: Arc<Transcoder>,
|
orchestrator: Arc<Transcoder>,
|
||||||
config: Arc<RwLock<crate::config::Config>>,
|
config: Arc<RwLock<crate::config::Config>>,
|
||||||
hw_info: Arc<Option<HardwareInfo>>,
|
hardware_state: HardwareState,
|
||||||
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
|
tx: Arc<broadcast::Sender<crate::db::AlchemistEvent>>,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -219,48 +222,98 @@ impl Pipeline {
|
|||||||
db,
|
db,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
config,
|
config,
|
||||||
hw_info,
|
hardware_state,
|
||||||
tx,
|
tx,
|
||||||
dry_run,
|
dry_run,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enqueue_discovered(&self, discovered: DiscoveredMedia) -> Result<()> {
|
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(
|
pub async fn enqueue_discovered_with_db(
|
||||||
db: &crate::db::Db,
|
db: &crate::db::Db,
|
||||||
discovered: DiscoveredMedia,
|
discovered: DiscoveredMedia,
|
||||||
) -> Result<()> {
|
) -> Result<bool> {
|
||||||
let settings = match db.get_file_settings().await {
|
let settings = match db.get_file_settings().await {
|
||||||
Ok(settings) => settings,
|
Ok(settings) => settings,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
|
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
|
||||||
crate::db::FileSettings {
|
default_file_settings()
|
||||||
id: 1,
|
|
||||||
delete_source: false,
|
|
||||||
output_extension: "mkv".to_string(),
|
|
||||||
output_suffix: "-alchemist".to_string(),
|
|
||||||
replace_strategy: "keep".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
let output_path = settings.output_path_for(&discovered.path);
|
||||||
if output_path.exists() && !settings.should_replace_existing_output() {
|
if output_path.exists() && !settings.should_replace_existing_output() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Skipping {:?} (output exists, replace_strategy = keep)",
|
"Skipping {:?} (output exists, replace_strategy = keep)",
|
||||||
discovered.path
|
discovered.path
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.enqueue_job(&discovered.path, &output_path, discovered.mtime)
|
db.enqueue_job(&discovered.path, &output_path, discovered.mtime)
|
||||||
.await
|
.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 {
|
impl Pipeline {
|
||||||
pub async fn process_job(&self, job: Job) -> std::result::Result<(), JobFailure> {
|
pub async fn process_job(&self, job: Job) -> std::result::Result<(), JobFailure> {
|
||||||
let file_path = PathBuf::from(&job.input_path);
|
let file_path = PathBuf::from(&job.input_path);
|
||||||
@@ -269,13 +322,7 @@ impl Pipeline {
|
|||||||
Ok(settings) => settings,
|
Ok(settings) => settings,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
|
tracing::error!("Failed to fetch file settings, using defaults: {}", e);
|
||||||
crate::db::FileSettings {
|
default_file_settings()
|
||||||
id: 1,
|
|
||||||
delete_source: false,
|
|
||||||
output_extension: "mkv".to_string(),
|
|
||||||
output_suffix: "-alchemist".to_string(),
|
|
||||||
replace_strategy: "keep".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,14 +408,14 @@ impl Pipeline {
|
|||||||
tracing::info!("[Job {}] Codec: {}", job.id, metadata.codec_name);
|
tracing::info!("[Job {}] Codec: {}", job.id, metadata.codec_name);
|
||||||
|
|
||||||
let config_snapshot = self.config.read().await.clone();
|
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 encoder_caps = Arc::new(crate::media::ffmpeg::encoder_caps_clone());
|
||||||
let planner = BasicPlanner::new(
|
let planner = BasicPlanner::new(
|
||||||
Arc::new(config_snapshot.clone()),
|
Arc::new(config_snapshot.clone()),
|
||||||
self.hw_info.as_ref().clone(),
|
hw_info.clone(),
|
||||||
encoder_caps.clone(),
|
encoder_caps.clone(),
|
||||||
);
|
);
|
||||||
let hardware_caps =
|
let hardware_caps = build_hardware_capabilities(&encoder_caps, hw_info.as_ref());
|
||||||
build_hardware_capabilities(&encoder_caps, self.hw_info.as_ref().as_ref());
|
|
||||||
let mut plan = match planner
|
let mut plan = match planner
|
||||||
.plan(&analysis, &hardware_caps, &file_settings.output_extension)
|
.plan(&analysis, &hardware_caps, &file_settings.output_extension)
|
||||||
.await
|
.await
|
||||||
@@ -422,6 +469,7 @@ impl Pipeline {
|
|||||||
self.emit_telemetry_event(TelemetryEventParams {
|
self.emit_telemetry_event(TelemetryEventParams {
|
||||||
telemetry_enabled: config_snapshot.system.enable_telemetry,
|
telemetry_enabled: config_snapshot.system.enable_telemetry,
|
||||||
output_codec: config_snapshot.transcode.output_codec,
|
output_codec: config_snapshot.transcode.output_codec,
|
||||||
|
encoder_override: None,
|
||||||
metadata,
|
metadata,
|
||||||
event_type: "job_started",
|
event_type: "job_started",
|
||||||
status: None,
|
status: None,
|
||||||
@@ -436,7 +484,7 @@ impl Pipeline {
|
|||||||
let executor = FfmpegExecutor::new(
|
let executor = FfmpegExecutor::new(
|
||||||
self.orchestrator.clone(),
|
self.orchestrator.clone(),
|
||||||
Arc::new(config_snapshot.clone()),
|
Arc::new(config_snapshot.clone()),
|
||||||
self.hw_info.as_ref().clone(),
|
hw_info.clone(),
|
||||||
self.tx.clone(),
|
self.tx.clone(),
|
||||||
self.dry_run,
|
self.dry_run,
|
||||||
);
|
);
|
||||||
@@ -451,7 +499,7 @@ impl Pipeline {
|
|||||||
return Err(JobFailure::EncoderUnavailable);
|
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
|
.await
|
||||||
.map_err(|_| JobFailure::Transient)
|
.map_err(|_| JobFailure::Transient)
|
||||||
}
|
}
|
||||||
@@ -476,6 +524,7 @@ impl Pipeline {
|
|||||||
self.emit_telemetry_event(TelemetryEventParams {
|
self.emit_telemetry_event(TelemetryEventParams {
|
||||||
telemetry_enabled: config_snapshot.system.enable_telemetry,
|
telemetry_enabled: config_snapshot.system.enable_telemetry,
|
||||||
output_codec: config_snapshot.transcode.output_codec,
|
output_codec: config_snapshot.transcode.output_codec,
|
||||||
|
encoder_override: None,
|
||||||
metadata,
|
metadata,
|
||||||
event_type: "job_finished",
|
event_type: "job_finished",
|
||||||
status: Some("failure"),
|
status: Some("failure"),
|
||||||
@@ -526,6 +575,7 @@ impl Pipeline {
|
|||||||
output_path: &Path,
|
output_path: &Path,
|
||||||
start_time: std::time::Instant,
|
start_time: std::time::Instant,
|
||||||
metadata: &MediaMetadata,
|
metadata: &MediaMetadata,
|
||||||
|
execution_result: &ExecutionResult,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let job_id = job.id;
|
let job_id = job.id;
|
||||||
let input_metadata = std::fs::metadata(input_path)?;
|
let input_metadata = std::fs::metadata(input_path)?;
|
||||||
@@ -546,7 +596,6 @@ impl Pipeline {
|
|||||||
|
|
||||||
let config = self.config.read().await;
|
let config = self.config.read().await;
|
||||||
let telemetry_enabled = config.system.enable_telemetry;
|
let telemetry_enabled = config.system.enable_telemetry;
|
||||||
let output_codec = config.transcode.output_codec;
|
|
||||||
|
|
||||||
if output_size == 0 || reduction < config.transcode.size_reduction_threshold {
|
if output_size == 0 || reduction < config.transcode.size_reduction_threshold {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -662,7 +711,10 @@ impl Pipeline {
|
|||||||
|
|
||||||
self.emit_telemetry_event(TelemetryEventParams {
|
self.emit_telemetry_event(TelemetryEventParams {
|
||||||
telemetry_enabled,
|
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,
|
metadata,
|
||||||
event_type: "job_finished",
|
event_type: "job_finished",
|
||||||
status: Some("success"),
|
status: Some("success"),
|
||||||
@@ -690,14 +742,20 @@ impl Pipeline {
|
|||||||
return;
|
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 {
|
let event = TelemetryEvent {
|
||||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
event_type: params.event_type.to_string(),
|
event_type: params.event_type.to_string(),
|
||||||
status: params.status.map(str::to_string),
|
status: params.status.map(str::to_string),
|
||||||
failure_reason: params.failure_reason.map(str::to_string),
|
failure_reason: params.failure_reason.map(str::to_string),
|
||||||
hardware_model: hardware_label(hw),
|
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()),
|
video_codec: Some(params.output_codec.as_str().to_string()),
|
||||||
resolution: resolution_bucket(params.metadata.width, params.metadata.height),
|
resolution: resolution_bucket(params.metadata.width, params.metadata.height),
|
||||||
duration_ms: params.duration_ms,
|
duration_ms: params.duration_ms,
|
||||||
@@ -713,6 +771,7 @@ impl Pipeline {
|
|||||||
struct TelemetryEventParams<'a> {
|
struct TelemetryEventParams<'a> {
|
||||||
telemetry_enabled: bool,
|
telemetry_enabled: bool,
|
||||||
output_codec: crate::config::OutputCodec,
|
output_codec: crate::config::OutputCodec,
|
||||||
|
encoder_override: Option<&'a str>,
|
||||||
metadata: &'a MediaMetadata,
|
metadata: &'a MediaMetadata,
|
||||||
event_type: &'a str,
|
event_type: &'a str,
|
||||||
status: Option<&'a str>,
|
status: Option<&'a str>,
|
||||||
@@ -731,3 +790,54 @@ fn map_failure(error: &crate::error::AlchemistError) -> JobFailure {
|
|||||||
_ => JobFailure::Transient,
|
_ => 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::error::Result;
|
||||||
use crate::media::pipeline::Pipeline;
|
use crate::media::pipeline::Pipeline;
|
||||||
use crate::media::scanner::Scanner;
|
use crate::media::scanner::Scanner;
|
||||||
use crate::system::hardware::HardwareInfo;
|
use crate::system::hardware::HardwareState;
|
||||||
use crate::Transcoder;
|
use crate::Transcoder;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
@@ -15,7 +15,7 @@ pub struct Agent {
|
|||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
orchestrator: Arc<Transcoder>,
|
orchestrator: Arc<Transcoder>,
|
||||||
config: Arc<RwLock<Config>>,
|
config: Arc<RwLock<Config>>,
|
||||||
hw_info: Arc<Option<HardwareInfo>>,
|
hardware_state: HardwareState,
|
||||||
tx: Arc<broadcast::Sender<AlchemistEvent>>,
|
tx: Arc<broadcast::Sender<AlchemistEvent>>,
|
||||||
semaphore: Arc<Semaphore>,
|
semaphore: Arc<Semaphore>,
|
||||||
semaphore_limit: Arc<AtomicUsize>,
|
semaphore_limit: Arc<AtomicUsize>,
|
||||||
@@ -30,7 +30,7 @@ impl Agent {
|
|||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
orchestrator: Arc<Transcoder>,
|
orchestrator: Arc<Transcoder>,
|
||||||
config: Arc<RwLock<Config>>,
|
config: Arc<RwLock<Config>>,
|
||||||
hw_info: Option<HardwareInfo>,
|
hardware_state: HardwareState,
|
||||||
tx: broadcast::Sender<AlchemistEvent>,
|
tx: broadcast::Sender<AlchemistEvent>,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -43,7 +43,7 @@ impl Agent {
|
|||||||
db,
|
db,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
config,
|
config,
|
||||||
hw_info: Arc::new(hw_info),
|
hardware_state,
|
||||||
tx: Arc::new(tx),
|
tx: Arc::new(tx),
|
||||||
semaphore: Arc::new(Semaphore::new(concurrent_jobs)),
|
semaphore: Arc::new(Semaphore::new(concurrent_jobs)),
|
||||||
semaphore_limit: Arc::new(AtomicUsize::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<()> {
|
pub async fn scan_and_enqueue(&self, directories: Vec<PathBuf>) -> Result<()> {
|
||||||
info!("Starting manual scan of directories: {:?}", directories);
|
info!("Starting manual scan of directories: {:?}", directories);
|
||||||
let scanner = Scanner::new();
|
let files = tokio::task::spawn_blocking(move || {
|
||||||
let files = scanner.scan(directories);
|
let scanner = Scanner::new();
|
||||||
|
scanner.scan(directories)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AlchemistError::Unknown(format!("scan task failed: {}", e)))?;
|
||||||
|
|
||||||
let pipeline = self.pipeline();
|
let pipeline = self.pipeline();
|
||||||
|
|
||||||
@@ -220,7 +224,7 @@ impl Agent {
|
|||||||
self.db.clone(),
|
self.db.clone(),
|
||||||
self.orchestrator.clone(),
|
self.orchestrator.clone(),
|
||||||
self.config.clone(),
|
self.config.clone(),
|
||||||
self.hw_info.clone(),
|
self.hardware_state.clone(),
|
||||||
self.tx.clone(),
|
self.tx.clone(),
|
||||||
self.dry_run,
|
self.dry_run,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::db::{AlchemistEvent, Db, NotificationTarget};
|
use crate::db::{AlchemistEvent, Db, NotificationTarget};
|
||||||
use reqwest::Client;
|
use reqwest::{redirect::Policy, Client, Url};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::lookup_host;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
@@ -14,7 +17,11 @@ impl NotificationManager {
|
|||||||
pub fn new(db: Db) -> Self {
|
pub fn new(db: Db) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db,
|
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,
|
event: &AlchemistEvent,
|
||||||
status: &str,
|
status: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
ensure_public_endpoint(&target.endpoint_url).await?;
|
||||||
|
|
||||||
match target.target_type.as_str() {
|
match target.target_type.as_str() {
|
||||||
"discord" => self.send_discord(target, event, status).await,
|
"discord" => self.send_discord(target, event, status).await,
|
||||||
"gotify" => self.send_gotify(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -34,6 +36,27 @@ pub struct HardwareInfo {
|
|||||||
pub supported_codecs: Vec<String>,
|
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 {
|
fn check_encoder_support(encoder: &str) -> bool {
|
||||||
let null_output = if cfg!(target_os = "windows") {
|
let null_output = if cfg!(target_os = "windows") {
|
||||||
"NUL"
|
"NUL"
|
||||||
@@ -458,3 +481,44 @@ pub async fn detect_hardware_async_with_preference(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AlchemistError::Config(format!("spawn_blocking failed: {}", e)))?
|
.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);
|
.or_insert(watch_dir.is_recursive);
|
||||||
}
|
}
|
||||||
|
|
||||||
let scanner = Scanner::new();
|
|
||||||
let mut all_scanned = Vec::new();
|
let mut all_scanned = Vec::new();
|
||||||
|
|
||||||
for (path, recursive) in scan_targets {
|
for (path, recursive) in scan_targets {
|
||||||
@@ -97,7 +96,19 @@ impl LibraryScanner {
|
|||||||
s.current_folder = Some(path.to_string_lossy().to_string());
|
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);
|
all_scanned.extend(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,21 +120,15 @@ impl LibraryScanner {
|
|||||||
|
|
||||||
let mut added = 0;
|
let mut added = 0;
|
||||||
for file in all_scanned {
|
for file in all_scanned {
|
||||||
let path_str = file.path.to_string_lossy().to_string();
|
match crate::media::pipeline::enqueue_discovered_with_db(&db, file).await {
|
||||||
|
Ok(changed) => {
|
||||||
// Check if already exists
|
if changed {
|
||||||
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 {
|
|
||||||
added += 1;
|
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 {
|
if added % 10 == 0 {
|
||||||
@@ -142,3 +147,72 @@ impl LibraryScanner {
|
|||||||
Ok(())
|
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::db::Db;
|
||||||
use crate::error::{AlchemistError, Result};
|
use crate::error::{AlchemistError, Result};
|
||||||
use crate::media::scanner::Scanner;
|
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::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -43,7 +46,9 @@ impl FileWatcher {
|
|||||||
pending.insert(path);
|
pending.insert(path);
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(debounce_ms)) => {
|
_ = 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() {
|
for path in pending.drain() {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
debug!("Auto-enqueuing new file: {:?}", path);
|
debug!("Auto-enqueuing new file: {:?}", path);
|
||||||
@@ -54,10 +59,10 @@ impl FileWatcher {
|
|||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
mtime,
|
mtime,
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::media::pipeline::enqueue_discovered_with_db(&db_clone, discovered).await {
|
match crate::media::pipeline::enqueue_discovered_with_db(&db_clone, discovered).await {
|
||||||
error!("Failed to auto-enqueue {:?}: {}", path, e);
|
Ok(true) => info!("Auto-enqueued: {:?}", path),
|
||||||
} else {
|
Ok(false) => debug!("No queue update needed for {:?}", path),
|
||||||
info!("Auto-enqueued: {:?}", path);
|
Err(e) => error!("Failed to auto-enqueue {:?}: {}", path, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,10 +106,13 @@ impl FileWatcher {
|
|||||||
let tx_clone = self.tx.clone();
|
let tx_clone = self.tx.clone();
|
||||||
|
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let mut watcher = RecommendedWatcher::new(
|
||||||
move |res: std::result::Result<Event, notify::Error>| {
|
move |res: std::result::Result<Event, notify::Error>| match res {
|
||||||
if let Ok(event) = res {
|
Ok(event) => {
|
||||||
|
if !should_enqueue_event(&event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
// Check if it's a media file
|
|
||||||
if let Some(ext) = path.extension() {
|
if let Some(ext) = path.extension() {
|
||||||
if extensions.contains(&ext.to_string_lossy().to_lowercase()) {
|
if extensions.contains(&ext.to_string_lossy().to_lowercase()) {
|
||||||
let _ = tx_clone.send(path);
|
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)),
|
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||||
)
|
)
|
||||||
@@ -145,3 +154,116 @@ impl FileWatcher {
|
|||||||
Ok(())
|
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",
|
"tailwind-merge": "^2.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -30,10 +32,14 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@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/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
|
||||||
|
|
||||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="],
|
"@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/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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||||
|
|
||||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
"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=="],
|
"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=="],
|
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||||
|
|
||||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
"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=="],
|
"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=="],
|
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
@@ -456,8 +506,12 @@
|
|||||||
|
|
||||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||||
|
|
||||||
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
|
"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=="],
|
"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=="],
|
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||||
|
|
||||||
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
|
"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=="],
|
"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=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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",
|
"name": "alchemist-web",
|
||||||
"version": "0.2.8",
|
"version": "0.2.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "bun@1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"check": "astro check",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview"
|
"preview": "astro preview",
|
||||||
|
"verify": "bun run typecheck && bun run check && bun run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/react": "4.4.2",
|
"@astrojs/react": "4.4.2",
|
||||||
@@ -20,6 +24,8 @@
|
|||||||
"tailwind-merge": "^2.0.0"
|
"tailwind-merge": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"autoprefixer": "^10.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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, Terminal, Server, Cpu, Activity, ShieldCheck } from "lucide-react";
|
import { X, Terminal, Server, Cpu, Activity, ShieldCheck, type LucideIcon } from "lucide-react";
|
||||||
import { apiFetch } from "../lib/api";
|
import { apiJson } from "../lib/api";
|
||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -16,17 +16,99 @@ interface AboutDialogProps {
|
|||||||
onClose: () => 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 AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||||
const [info, setInfo] = useState<SystemInfo | null>(null);
|
const [info, setInfo] = useState<SystemInfo | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !info) {
|
if (isOpen && !info) {
|
||||||
apiFetch("/api/system/info")
|
apiJson<SystemInfo>("/api/system/info")
|
||||||
.then(res => res.json())
|
|
||||||
.then(setInfo)
|
.then(setInfo)
|
||||||
.catch(console.error);
|
.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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -44,6 +126,11 @@ export default function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
onClick={e => e.stopPropagation()}
|
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"
|
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" />
|
<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>
|
||||||
|
|
||||||
<div className="mb-8">
|
<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>
|
<p className="text-helios-slate font-medium">Media Transcoding Agent</p>
|
||||||
</div>
|
</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 (
|
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 justify-between p-3 rounded-xl bg-helios-surface-soft border border-helios-line/10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { apiFetch } from "../lib/api";
|
import { apiAction, isApiError } from "../lib/api";
|
||||||
|
|
||||||
function cn(...inputs: ClassValue[]) {
|
function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -284,14 +284,12 @@ const applyRootTheme = (themeId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AppearanceSettings() {
|
export default function AppearanceSettings() {
|
||||||
// Initialize from local storage or default
|
|
||||||
const [activeThemeId, setActiveThemeId] = useState(
|
const [activeThemeId, setActiveThemeId] = useState(
|
||||||
() => (typeof window !== 'undefined' ? localStorage.getItem("theme") : null) || getRootTheme() || "helios-orange"
|
() => (typeof window !== 'undefined' ? localStorage.getItem("theme") : null) || getRootTheme() || "helios-orange"
|
||||||
);
|
);
|
||||||
const [savingThemeId, setSavingThemeId] = useState<string | null>(null);
|
const [savingThemeId, setSavingThemeId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
// Effect to ensure theme is applied on mount (if mismatched)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyRootTheme(activeThemeId);
|
applyRootTheme(activeThemeId);
|
||||||
}, [activeThemeId]);
|
}, [activeThemeId]);
|
||||||
@@ -302,33 +300,21 @@ export default function AppearanceSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _previousTheme = activeThemeId;
|
|
||||||
setActiveThemeId(themeId);
|
setActiveThemeId(themeId);
|
||||||
setSavingThemeId(themeId);
|
setSavingThemeId(themeId);
|
||||||
setError("");
|
setError("");
|
||||||
applyRootTheme(themeId);
|
applyRootTheme(themeId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine API endpoint.
|
await apiAction("/api/ui/preferences", {
|
||||||
// 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", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ active_theme_id: themeId }),
|
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) {
|
} catch (saveError) {
|
||||||
console.warn("Theme save failed, using local storage fallback", saveError);
|
if (isApiError(saveError) && saveError.status === 404) {
|
||||||
// We don't revert here because we want the UI to update immediately and persist locally at least.
|
return;
|
||||||
// setError("Unable to save theme preference to server.");
|
}
|
||||||
|
setError("Unable to save theme preference to server.");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingThemeId(null);
|
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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -6,16 +6,13 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Database,
|
Database,
|
||||||
Zap,
|
Zap,
|
||||||
Terminal
|
Terminal,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiFetch } from "../lib/api";
|
import { apiJson, isApiError } from "../lib/api";
|
||||||
|
import { useSharedStats } from "../lib/statsStore";
|
||||||
interface Stats {
|
import { showToast } from "../lib/toast";
|
||||||
total: number;
|
import ResourceMonitor from "./ResourceMonitor";
|
||||||
completed: number;
|
|
||||||
active: number;
|
|
||||||
failed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,43 +21,94 @@ interface Job {
|
|||||||
created_at: string;
|
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() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<Stats>({ total: 0, completed: 0, active: 0, failed: 0 });
|
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [jobsLoading, setJobsLoading] = useState(true);
|
||||||
|
const { stats: sharedStats, error: statsError } = useSharedStats();
|
||||||
const _lastJob = jobs[0];
|
const stats = sharedStats ?? DEFAULT_STATS;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (!statsError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
kind: "error",
|
||||||
|
title: "Stats",
|
||||||
|
message: statsError,
|
||||||
|
});
|
||||||
|
}, [statsError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchJobs = async () => {
|
||||||
try {
|
try {
|
||||||
const [statsRes, jobsRes] = await Promise.all([
|
const data = await apiJson<Job[]>(
|
||||||
apiFetch("/api/stats"),
|
`/api/jobs/table?${new URLSearchParams({
|
||||||
apiFetch(`/api/jobs/table?${new URLSearchParams({
|
|
||||||
limit: "5",
|
limit: "5",
|
||||||
sort: "created_at",
|
sort: "created_at",
|
||||||
sort_desc: "true",
|
sort_desc: "true",
|
||||||
})}`)
|
})}`
|
||||||
]);
|
);
|
||||||
|
setJobs(data);
|
||||||
if (statsRes.ok) {
|
} catch (error) {
|
||||||
setStats(await statsRes.json());
|
const message = isApiError(error) ? error.message : "Failed to fetch jobs";
|
||||||
}
|
showToast({ kind: "error", title: "Dashboard", message });
|
||||||
if (jobsRes.ok) {
|
|
||||||
setJobs(await jobsRes.json());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Dashboard fetch error", e);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setJobsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetchData();
|
void fetchJobs();
|
||||||
const interval = setInterval(fetchData, 5000);
|
|
||||||
return () => clearInterval(interval);
|
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) => {
|
const formatRelativeTime = (iso?: string) => {
|
||||||
@@ -77,8 +125,9 @@ export default function Dashboard() {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickStartItems = useMemo(() => {
|
const quickStartItems = useMemo<QuickStartItem[]>(() => {
|
||||||
const items = [];
|
const items: QuickStartItem[] = [];
|
||||||
|
|
||||||
if (stats.total === 0) {
|
if (stats.total === 0) {
|
||||||
items.push({
|
items.push({
|
||||||
title: "Connect Your Library",
|
title: "Connect Your Library",
|
||||||
@@ -100,6 +149,7 @@ export default function Dashboard() {
|
|||||||
bg: "bg-helios-solar/10",
|
bg: "bg-helios-solar/10",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.failed > 0) {
|
if (stats.failed > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
title: "Review Failures",
|
title: "Review Failures",
|
||||||
@@ -117,6 +167,7 @@ export default function Dashboard() {
|
|||||||
bg: "bg-red-500/10",
|
bg: "bg-red-500/10",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.active === 0 && stats.total > 0) {
|
if (stats.active === 0 && stats.total > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
title: "Queue New Work",
|
title: "Queue New Work",
|
||||||
@@ -134,6 +185,7 @@ export default function Dashboard() {
|
|||||||
bg: "bg-emerald-500/10",
|
bg: "bg-emerald-500/10",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
items.push({
|
items.push({
|
||||||
title: "Optimize Throughput",
|
title: "Optimize Throughput",
|
||||||
@@ -151,53 +203,20 @@ export default function Dashboard() {
|
|||||||
bg: "bg-amber-500/10",
|
bg: "bg-amber-500/10",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.slice(0, 3);
|
return items.slice(0, 3);
|
||||||
}, [stats.active, stats.failed, stats.total]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-hidden">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<StatCard
|
<StatCard label="Active Jobs" value={stats.active} icon={Zap} colorClass="text-amber-500" />
|
||||||
label="Active Jobs"
|
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} colorClass="text-emerald-500" />
|
||||||
value={stats.active}
|
<StatCard label="Failed" value={stats.failed} icon={AlertCircle} colorClass="text-red-500" />
|
||||||
icon={Zap}
|
<StatCard label="Total Processed" value={stats.total} icon={Database} colorClass="text-helios-solar" />
|
||||||
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
<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="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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
|
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
|
||||||
@@ -208,7 +227,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
<div className="text-center py-8 text-helios-slate animate-pulse">Loading activity...</div>
|
||||||
) : jobs.length === 0 ? (
|
) : jobs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-helios-slate/60 italic">No recent activity found.</div>
|
<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 (
|
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 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="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)]' :
|
<div className={`w-2 h-2 rounded-full shrink-0 ${status === "completed"
|
||||||
status === 'failed' ? 'bg-red-500' :
|
? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]"
|
||||||
status === 'encoding' || status === 'analyzing' ? 'bg-amber-500 animate-pulse' :
|
: status === "failed"
|
||||||
'bg-helios-slate'
|
? "bg-red-500"
|
||||||
|
: status === "encoding" || status === "analyzing"
|
||||||
|
? "bg-amber-500 animate-pulse"
|
||||||
|
: "bg-helios-slate"
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-sm font-medium text-helios-ink truncate" title={job.input_path}>
|
<span className="text-sm font-medium text-helios-ink truncate" title={job.input_path}>
|
||||||
@@ -242,7 +264,6 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Getting Started Tips */}
|
|
||||||
<div className="p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm h-full">
|
<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">
|
<h3 className="text-lg font-bold text-helios-ink mb-6 flex items-center gap-2">
|
||||||
<Zap size={20} className="text-helios-solar" />
|
<Zap size={20} className="text-helios-solar" />
|
||||||
@@ -256,9 +277,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-helios-ink">{title}</h4>
|
<h4 className="text-sm font-bold text-helios-ink">{title}</h4>
|
||||||
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
|
<p className="text-xs text-helios-slate mt-1 leading-relaxed">{body}</p>
|
||||||
{body}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileOutput, AlertTriangle, Save } from "lucide-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 {
|
interface FileSettings {
|
||||||
delete_source: boolean;
|
delete_source: boolean;
|
||||||
@@ -14,10 +15,11 @@ export default function FileSettings() {
|
|||||||
delete_source: false,
|
delete_source: false,
|
||||||
output_extension: "mkv",
|
output_extension: "mkv",
|
||||||
output_suffix: "-alchemist",
|
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 [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchSettings();
|
void fetchSettings();
|
||||||
@@ -25,29 +27,38 @@ export default function FileSettings() {
|
|||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/files");
|
const data = await apiJson<FileSettings>("/api/settings/files");
|
||||||
if (res.ok) setSettings(await res.json());
|
setSettings(data);
|
||||||
} catch (e) { console.error(e); }
|
setError(null);
|
||||||
finally { setLoading(false); }
|
} catch (e) {
|
||||||
|
const message = isApiError(e) ? e.message : "Failed to load file settings";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/settings/files", {
|
await apiAction("/api/settings/files", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(settings)
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
|
showToast({ kind: "success", title: "Files", message: "File settings saved." });
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="flex items-center gap-3 mb-6">
|
||||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||||
<FileOutput className="text-helios-solar" size={20} />
|
<FileOutput className="text-helios-solar" size={20} />
|
||||||
@@ -58,66 +69,77 @@ export default function FileSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{error && (
|
||||||
{/* Naming */}
|
<div className="p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{error}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Deletion Policy */}
|
{loading ? (
|
||||||
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl space-y-3">
|
<div className="text-sm text-helios-slate animate-pulse">Loading settings…</div>
|
||||||
<div className="flex items-start gap-3">
|
) : (
|
||||||
<AlertTriangle className="text-red-500 shrink-0 mt-0.5" size={16} />
|
<div className="space-y-4">
|
||||||
<div className="flex-1">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<h3 className="text-sm font-bold text-red-600 dark:text-red-400">Destructive Policy</h3>
|
<div>
|
||||||
<p className="text-xs text-helios-slate mt-1 mb-3">
|
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Output Suffix</label>
|
||||||
Enabling "Delete Source" will permanently remove the original file after a successful transcode. This action cannot be undone.
|
<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>
|
</p>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
</div>
|
||||||
<input
|
<div>
|
||||||
type="checkbox"
|
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Extension</label>
|
||||||
checked={settings.delete_source}
|
<select
|
||||||
onChange={e => setSettings({ ...settings, delete_source: e.target.checked })}
|
value={settings.output_extension}
|
||||||
className="rounded border-red-500/30 text-red-500 focus:ring-red-500 bg-red-500/10"
|
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"
|
||||||
<span className="text-sm font-medium text-helios-ink">Delete source file after success</span>
|
>
|
||||||
</label>
|
<option value="mkv">mkv</option>
|
||||||
|
<option value="mp4">mp4</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="p-4 bg-red-500/5 border border-red-500/20 rounded-xl space-y-3">
|
||||||
<button
|
<div className="flex items-start gap-3">
|
||||||
onClick={handleSave}
|
<AlertTriangle className="text-red-500 shrink-0 mt-0.5" size={16} />
|
||||||
disabled={saving}
|
<div className="flex-1">
|
||||||
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"
|
<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">
|
||||||
<Save size={16} />
|
Enabling "Delete Source" will permanently remove the original file after a successful transcode. This action cannot be undone.
|
||||||
{saving ? "Saving..." : "Save Settings"}
|
</p>
|
||||||
</button>
|
<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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Cpu, Zap, HardDrive, CheckCircle2, AlertCircle } from "lucide-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 {
|
interface HardwareInfo {
|
||||||
vendor: "Nvidia" | "Amd" | "Intel" | "Apple" | "Cpu";
|
vendor: "Nvidia" | "Amd" | "Intel" | "Apple" | "Cpu";
|
||||||
@@ -23,33 +24,27 @@ export default function HardwareSettings() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchHardware();
|
void Promise.all([fetchHardware(), fetchSettings()]).finally(() => setLoading(false));
|
||||||
void fetchSettings();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchHardware = async () => {
|
const fetchHardware = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/system/hardware");
|
const data = await apiJson<HardwareInfo>("/api/system/hardware");
|
||||||
if (!res.ok) throw new Error("Failed to detect hardware");
|
|
||||||
const data = await res.json();
|
|
||||||
setInfo(data);
|
setInfo(data);
|
||||||
|
setError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Unable to detect hardware acceleration support.");
|
setError(isApiError(err) ? err.message : "Unable to detect hardware acceleration support.");
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/hardware");
|
const data = await apiJson<HardwareSettings>("/api/settings/hardware");
|
||||||
if (res.ok) {
|
setSettings(data);
|
||||||
const data = await res.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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;
|
if (!settings) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/hardware", {
|
await apiAction("/api/settings/hardware", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...settings, allow_cpu_encoding: enabled }),
|
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) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,7 @@ export default function HardwareSettings() {
|
|||||||
|
|
||||||
if (error || !info) {
|
if (error || !info) {
|
||||||
return (
|
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} />
|
<AlertCircle size={20} />
|
||||||
<span className="font-semibold">{error || "Hardware detection failed."}</span>
|
<span className="font-semibold">{error || "Hardware detection failed."}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +100,7 @@ export default function HardwareSettings() {
|
|||||||
const details = getVendorDetails(info.vendor);
|
const details = getVendorDetails(info.vendor);
|
||||||
|
|
||||||
return (
|
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 className="flex items-center justify-between pb-2 border-b border-helios-line/10">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">Transcoding Hardware</h3>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CPU Encoding Toggle */}
|
|
||||||
{settings && (
|
{settings && (
|
||||||
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
|
<div className="bg-helios-surface border border-helios-line/30 rounded-2xl p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -192,15 +188,11 @@ export default function HardwareSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateCpuEncoding(!settings.allow_cpu_encoding)}
|
onClick={() => void updateCpuEncoding(!settings.allow_cpu_encoding)}
|
||||||
disabled={saving}
|
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'
|
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"}`}
|
||||||
} ${saving ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<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"}`} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { useState } from "react";
|
|||||||
import { Info, LogOut } from "lucide-react";
|
import { Info, LogOut } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
|
import { apiAction } from "../lib/api";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
export default function HeaderActions() {
|
export default function HeaderActions() {
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
await apiAction("/api/auth/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore logout failures and continue redirecting to login.
|
showToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Logout request failed. Redirecting to login.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
Search, RefreshCw, Trash2, Ban,
|
Search, RefreshCw, Trash2, Ban,
|
||||||
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal
|
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal
|
||||||
} from "lucide-react";
|
} 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 { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -12,6 +15,21 @@ function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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 {
|
interface Job {
|
||||||
id: number;
|
id: number;
|
||||||
input_path: string;
|
input_path: string;
|
||||||
@@ -64,13 +82,18 @@ export default function JobManager() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("all");
|
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 [page, setPage] = useState(1);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [focusedJob, setFocusedJob] = useState<JobDetail | null>(null);
|
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 [menuJobId, setMenuJobId] = useState<number | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement | 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<{
|
const [confirmState, setConfirmState] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -90,8 +113,10 @@ export default function JobManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchJobs = useCallback(async () => {
|
const fetchJobs = useCallback(async (silent = false) => {
|
||||||
setRefreshing(true);
|
if (!silent) {
|
||||||
|
setRefreshing(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: "50",
|
limit: "50",
|
||||||
@@ -103,29 +128,54 @@ export default function JobManager() {
|
|||||||
if (activeTab !== "all") {
|
if (activeTab !== "all") {
|
||||||
params.set("status", getStatusFilter(activeTab));
|
params.set("status", getStatusFilter(activeTab));
|
||||||
}
|
}
|
||||||
if (search) {
|
if (debouncedSearch) {
|
||||||
params.set("search", search);
|
params.set("search", debouncedSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiFetch(`/api/jobs/table?${params}`);
|
const data = await apiJson<Job[]>(`/api/jobs/table?${params}`);
|
||||||
if (res.ok) {
|
setJobs(data);
|
||||||
const data = await res.json();
|
setActionError(null);
|
||||||
setJobs(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
if (!silent) {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [activeTab, search, page]);
|
}, [activeTab, debouncedSearch, page]);
|
||||||
|
|
||||||
|
const fetchJobsRef = useRef<() => Promise<void>>(async () => undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchJobs();
|
fetchJobsRef.current = async () => {
|
||||||
const interval = setInterval(fetchJobs, 5000); // Auto-refresh every 5s
|
await fetchJobs(true);
|
||||||
return () => clearInterval(interval);
|
};
|
||||||
}, [fetchJobs]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!menuJobId) return;
|
if (!menuJobId) return;
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
@@ -137,6 +187,76 @@ export default function JobManager() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [menuJobId]);
|
}, [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 toggleSelect = (id: number) => {
|
||||||
const newSet = new Set(selected);
|
const newSet = new Set(selected);
|
||||||
if (newSet.has(id)) newSet.delete(id);
|
if (newSet.has(id)) newSet.delete(id);
|
||||||
@@ -154,55 +274,77 @@ export default function JobManager() {
|
|||||||
|
|
||||||
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
|
const handleBatch = async (action: "cancel" | "restart" | "delete") => {
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0) return;
|
||||||
|
setActionError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/jobs/batch", {
|
await apiAction("/api/jobs/batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action,
|
action,
|
||||||
ids: Array.from(selected)
|
ids: Array.from(selected)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
setSelected(new Set());
|
||||||
if (res.ok) {
|
showToast({
|
||||||
setSelected(new Set());
|
kind: "success",
|
||||||
await fetchJobs();
|
title: "Jobs",
|
||||||
}
|
message: `${action[0].toUpperCase()}${action.slice(1)} request sent for selected jobs.`,
|
||||||
|
});
|
||||||
|
await fetchJobs();
|
||||||
} catch (e) {
|
} 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 () => {
|
const clearCompleted = async () => {
|
||||||
await apiFetch("/api/jobs/clear-completed", { method: "POST" });
|
setActionError(null);
|
||||||
await fetchJobs();
|
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) => {
|
const fetchJobDetails = async (id: number) => {
|
||||||
|
setActionError(null);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/jobs/${id}/details`);
|
const data = await apiJson<JobDetail>(`/api/jobs/${id}/details`);
|
||||||
if (res.ok) {
|
setFocusedJob(data);
|
||||||
const data = await res.json();
|
|
||||||
setFocusedJob(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => {
|
const handleAction = async (id: number, action: "cancel" | "restart" | "delete") => {
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/jobs/${id}/${action}`, { method: "POST" });
|
await apiAction(`/api/jobs/${id}/${action}`, { method: "POST" });
|
||||||
if (res.ok) {
|
if (action === "delete") {
|
||||||
if (action === "delete") setFocusedJob(null);
|
setFocusedJob((current) => (current?.job.id === id ? null : current));
|
||||||
else await fetchJobDetails(id);
|
} else if (focusedJob?.job.id === id) {
|
||||||
await fetchJobs();
|
await fetchJobDetails(id);
|
||||||
}
|
}
|
||||||
|
await fetchJobs();
|
||||||
|
showToast({
|
||||||
|
kind: "success",
|
||||||
|
title: "Jobs",
|
||||||
|
message: `Job ${action} request completed.`,
|
||||||
|
});
|
||||||
} catch (e) {
|
} 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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={search}
|
value={searchInput}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
<button
|
<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")}
|
className={cn("p-2 rounded-lg border border-helios-line/20 hover:bg-helios-surface-soft", refreshing && "animate-spin")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
@@ -289,6 +431,12 @@ export default function JobManager() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Batch Actions Bar */}
|
||||||
{selected.size > 0 && (
|
{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">
|
<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) => (
|
jobs.map((job) => (
|
||||||
<tr
|
<tr
|
||||||
key={job.id}
|
key={job.id}
|
||||||
onClick={() => fetchJobDetails(job.id)}
|
onClick={() => void fetchJobDetails(job.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group hover:bg-helios-surface/80 transition-all cursor-pointer",
|
"group hover:bg-helios-surface/80 transition-all cursor-pointer",
|
||||||
selected.has(job.id) && "bg-helios-surface-soft",
|
selected.has(job.id) && "bg-helios-surface-soft",
|
||||||
@@ -549,6 +697,12 @@ export default function JobManager() {
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
transition={{ duration: 0.2 }}
|
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"
|
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 */}
|
{/* Header */}
|
||||||
@@ -558,10 +712,10 @@ export default function JobManager() {
|
|||||||
{getStatusBadge(focusedJob.job.status)}
|
{getStatusBadge(focusedJob.job.status)}
|
||||||
<span className="text-[10px] uppercase font-bold tracking-widest text-helios-slate">Job ID #{focusedJob.job.id}</span>
|
<span className="text-[10px] uppercase font-bold tracking-widest text-helios-slate">Job ID #{focusedJob.job.id}</span>
|
||||||
</div>
|
</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()}
|
{focusedJob.job.input_path.split(/[/\\]/).pop()}
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFocusedJob(null)}
|
onClick={() => setFocusedJob(null)}
|
||||||
@@ -572,6 +726,9 @@ export default function JobManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-8 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
<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 */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
<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">
|
<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>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Confirm Modal */}
|
<ConfirmDialog
|
||||||
<AnimatePresence>
|
open={confirmState !== null}
|
||||||
{confirmState && (
|
title={confirmState?.title ?? ""}
|
||||||
<>
|
description={confirmState?.body ?? ""}
|
||||||
<motion.div
|
confirmLabel={confirmState?.confirmLabel ?? "Confirm"}
|
||||||
initial={{ opacity: 0 }}
|
tone={confirmState?.confirmTone ?? "primary"}
|
||||||
animate={{ opacity: 1 }}
|
onClose={() => setConfirmState(null)}
|
||||||
exit={{ opacity: 0 }}
|
onConfirm={async () => {
|
||||||
onClick={() => setConfirmState(null)}
|
if (!confirmState) {
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[120]"
|
return;
|
||||||
/>
|
}
|
||||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[121]">
|
await confirmState.onConfirm();
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Terminal, Pause, Play, Trash2, RefreshCw } from "lucide-react";
|
import { Terminal, Pause, Play, Trash2, RefreshCw } from "lucide-react";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
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[]) {
|
function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -21,37 +23,39 @@ export default function LogViewer() {
|
|||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const pausedRef = useRef(paused);
|
const pausedRef = useRef(paused);
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
const maxLogs = 1000;
|
const maxLogs = 1000;
|
||||||
|
|
||||||
// Sync ref
|
useEffect(() => {
|
||||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
pausedRef.current = paused;
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/logs/history?limit=200");
|
const history = await apiJson<LogEntry[]>("/api/logs/history?limit=200");
|
||||||
if (res.ok) {
|
setLogs(history.reverse());
|
||||||
const history = await res.json();
|
setStreamError(null);
|
||||||
// Logs come newest first (DESC), reverse for display
|
|
||||||
setLogs(history.reverse());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch logs", e);
|
const message = isApiError(e) ? e.message : "Failed to fetch logs";
|
||||||
|
setStreamError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearLogs = async () => {
|
const clearLogs = async () => {
|
||||||
if (!confirm("Are you sure you want to clear all server logs?")) return;
|
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/logs", { method: "DELETE" });
|
await apiAction("/api/logs", { method: "DELETE" });
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
|
showToast({ kind: "success", title: "Logs", message: "Server logs cleared." });
|
||||||
} catch (e) {
|
} 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 eventSource: EventSource | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (cancelled) return;
|
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) => {
|
setStreamError(null);
|
||||||
if (pausedRef.current) return;
|
eventSource?.close();
|
||||||
|
eventSource = new EventSource("/api/events");
|
||||||
|
|
||||||
|
const appendLog = (message: string, level: string, jobId?: number) => {
|
||||||
|
if (pausedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entry: LogEntry = {
|
const entry: LogEntry = {
|
||||||
id: Date.now() + Math.random(),
|
id: Date.now() + Math.random(),
|
||||||
level,
|
level,
|
||||||
message: msg,
|
message,
|
||||||
job_id,
|
job_id: jobId,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setLogs(prev => {
|
setLogs((prev) => {
|
||||||
const newLogs = [...prev, entry];
|
const next = [...prev, entry];
|
||||||
if (newLogs.length > maxLogs) return newLogs.slice(newLogs.length - maxLogs);
|
if (next.length > maxLogs) {
|
||||||
return newLogs;
|
return next.slice(next.length - maxLogs);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.addEventListener("log", (e) => {
|
eventSource.addEventListener("log", (event) => {
|
||||||
|
const data = event.data;
|
||||||
try {
|
try {
|
||||||
// Expecting simple text or JSON?
|
const parsed = JSON.parse(data) as { message?: string; level?: string; job_id?: number };
|
||||||
// Backend sends AlchemistEvent::Log { level, job_id, message }
|
if (parsed.message) {
|
||||||
// But SSE serializer matches structure.
|
appendLog(parsed.message, parsed.level ?? "info", parsed.job_id);
|
||||||
// Wait, existing SSE in server.rs sends plain text or JSON?
|
return;
|
||||||
// Let's check server.rs sse_handler or Event impl.
|
}
|
||||||
// Assuming existing impl sends `data: message` for "log" event.
|
} catch {
|
||||||
// But I added structured event in backend: AlchemistEvent::Log
|
// Fall back to plain text handling.
|
||||||
// If server.rs uses `sse::Event::default().event("log").data(...)`
|
}
|
||||||
|
|
||||||
// Actually, I need to check `sse_handler` in `server.rs` to see what it sends.
|
appendLog(data, data.toLowerCase().includes("error") ? "error" : "info");
|
||||||
// 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 { }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener("decision", (e) => {
|
eventSource.addEventListener("decision", (event) => {
|
||||||
try { const d = JSON.parse(e.data); handleMsg(`Decision: ${d.action.toUpperCase()} - ${d.reason}`, "info", d.job_id); } catch { }
|
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.onerror = () => {
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
setStreamError("Log stream unavailable. Please check authentication.");
|
setStreamError("Log stream unavailable. Reconnecting…");
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
|
if (reconnectTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(reconnectTimeoutRef.current);
|
window.clearTimeout(reconnectTimeoutRef.current);
|
||||||
}
|
}
|
||||||
reconnectTimeoutRef.current = window.setTimeout(connect, 3000);
|
reconnectTimeoutRef.current = window.setTimeout(connect, 3000);
|
||||||
@@ -137,7 +142,7 @@ export default function LogViewer() {
|
|||||||
connect();
|
connect();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(reconnectTimeoutRef.current);
|
window.clearTimeout(reconnectTimeoutRef.current);
|
||||||
reconnectTimeoutRef.current = null;
|
reconnectTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -145,7 +150,6 @@ export default function LogViewer() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paused && scrollRef.current) {
|
if (!paused && scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
@@ -155,20 +159,22 @@ export default function LogViewer() {
|
|||||||
const formatTime = (iso: string) => {
|
const formatTime = (iso: string) => {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleTimeString();
|
return new Date(iso).toLocaleTimeString();
|
||||||
} catch { return iso; }
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full rounded-2xl border border-helios-line/40 bg-[#0d1117] overflow-hidden shadow-2xl">
|
<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 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} />
|
<Terminal size={16} />
|
||||||
<span className="text-xs font-bold uppercase tracking-widest">Server Logs</span>
|
<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>}
|
{loading && <span className="text-xs animate-pulse opacity-50 ml-2">Loading history...</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={fetchHistory}
|
onClick={() => void fetchHistory()}
|
||||||
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
|
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
|
||||||
title="Reload History"
|
title="Reload History"
|
||||||
>
|
>
|
||||||
@@ -182,7 +188,7 @@ export default function LogViewer() {
|
|||||||
{paused ? <Play size={14} /> : <Pause size={14} />}
|
{paused ? <Play size={14} /> : <Pause size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="p-1.5 rounded-lg hover:bg-red-500/10 text-helios-slate hover:text-red-400 transition-colors"
|
||||||
title="Clear Server Logs"
|
title="Clear Server Logs"
|
||||||
>
|
>
|
||||||
@@ -194,12 +200,9 @@ export default function LogViewer() {
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
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"
|
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 && (
|
{streamError && <div className="text-amber-400 text-center py-4 text-[11px] font-semibold">{streamError}</div>}
|
||||||
<div className="text-amber-400 text-center py-4 text-[11px] font-semibold">
|
|
||||||
{streamError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{logs.length === 0 && !loading && !streamError && (
|
{logs.length === 0 && !loading && !streamError && (
|
||||||
<div className="text-helios-slate/30 text-center py-10 italic">No logs found.</div>
|
<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">
|
<div className="flex-1 min-w-0 break-all">
|
||||||
{log.job_id && (
|
{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(
|
<span
|
||||||
log.level.toLowerCase().includes("error") ? "text-red-400 font-bold" :
|
className={cn(
|
||||||
log.level.toLowerCase().includes("warn") ? "text-amber-400" :
|
log.level.toLowerCase().includes("error")
|
||||||
"text-white/90"
|
? "text-red-400 font-bold"
|
||||||
)}>
|
: log.level.toLowerCase().includes("warn")
|
||||||
|
? "text-amber-400"
|
||||||
|
: "text-white/90"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{log.message}
|
{log.message}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Bell, Plus, Trash2, Zap } from "lucide-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 {
|
interface NotificationTarget {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
target_type: 'gotify' | 'discord' | 'webhook';
|
target_type: "gotify" | "discord" | "webhook";
|
||||||
endpoint_url: string;
|
endpoint_url: string;
|
||||||
auth_token?: string;
|
auth_token?: string;
|
||||||
events: string; // JSON string
|
events: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TARGET_TYPES: NotificationTarget["target_type"][] = ["discord", "gotify", "webhook"];
|
||||||
|
|
||||||
export default function NotificationSettings() {
|
export default function NotificationSettings() {
|
||||||
const [targets, setTargets] = useState<NotificationTarget[]>([]);
|
const [targets, setTargets] = useState<NotificationTarget[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [testingId, setTestingId] = useState<number | null>(null);
|
const [testingId, setTestingId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
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 [newUrl, setNewUrl] = useState("");
|
||||||
const [newToken, setNewToken] = useState("");
|
const [newToken, setNewToken] = useState("");
|
||||||
const [newEvents, setNewEvents] = useState<string[]>(["completed", "failed"]);
|
const [newEvents, setNewEvents] = useState<string[]>(["completed", "failed"]);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchTargets();
|
void fetchTargets();
|
||||||
@@ -31,13 +36,12 @@ export default function NotificationSettings() {
|
|||||||
|
|
||||||
const fetchTargets = async () => {
|
const fetchTargets = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/notifications");
|
const data = await apiJson<NotificationTarget[]>("/api/settings/notifications");
|
||||||
if (res.ok) {
|
setTargets(data);
|
||||||
const data = await res.json();
|
setError(null);
|
||||||
setTargets(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
const message = isApiError(e) ? e.message : "Failed to load notification targets";
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,7 @@ export default function NotificationSettings() {
|
|||||||
const handleAdd = async (e: React.FormEvent) => {
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/notifications", {
|
await apiAction("/api/settings/notifications", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -55,28 +59,33 @@ export default function NotificationSettings() {
|
|||||||
endpoint_url: newUrl,
|
endpoint_url: newUrl,
|
||||||
auth_token: newToken || null,
|
auth_token: newToken || null,
|
||||||
events: newEvents,
|
events: newEvents,
|
||||||
enabled: true
|
enabled: true,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
setShowForm(false);
|
||||||
setShowForm(false);
|
setNewName("");
|
||||||
setNewName("");
|
setNewUrl("");
|
||||||
setNewUrl("");
|
setNewToken("");
|
||||||
setNewToken("");
|
setError(null);
|
||||||
await fetchTargets();
|
await fetchTargets();
|
||||||
}
|
showToast({ kind: "success", title: "Notifications", message: "Target added." });
|
||||||
} catch (e) {
|
} 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) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm("Remove this notification target?")) return;
|
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/settings/notifications/${id}`, { method: "DELETE" });
|
await apiAction(`/api/settings/notifications/${id}`, { method: "DELETE" });
|
||||||
|
setError(null);
|
||||||
await fetchTargets();
|
await fetchTargets();
|
||||||
|
showToast({ kind: "success", title: "Notifications", message: "Target removed." });
|
||||||
} catch (e) {
|
} 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 = [];
|
events = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiFetch("/api/settings/notifications/test", {
|
await apiAction("/api/settings/notifications/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -101,19 +110,16 @@ export default function NotificationSettings() {
|
|||||||
target_type: target.target_type,
|
target_type: target.target_type,
|
||||||
endpoint_url: target.endpoint_url,
|
endpoint_url: target.endpoint_url,
|
||||||
auth_token: target.auth_token,
|
auth_token: target.auth_token,
|
||||||
events: events,
|
events,
|
||||||
enabled: target.enabled
|
enabled: target.enabled,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
showToast({ kind: "success", title: "Notifications", message: "Test notification sent." });
|
||||||
alert("Test notification sent!");
|
|
||||||
} else {
|
|
||||||
alert("Test failed.");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
const message = isApiError(e) ? e.message : "Test notification failed";
|
||||||
alert("Test error");
|
setError(message);
|
||||||
|
showToast({ kind: "error", title: "Notifications", message });
|
||||||
} finally {
|
} finally {
|
||||||
setTestingId(null);
|
setTestingId(null);
|
||||||
}
|
}
|
||||||
@@ -128,7 +134,7 @@ export default function NotificationSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||||
@@ -148,6 +154,12 @@ export default function NotificationSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{showForm && (
|
||||||
<form onSubmit={handleAdd} className="bg-helios-surface-soft p-4 rounded-xl space-y-4 border border-helios-line/20 mb-6">
|
<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">
|
<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>
|
<label className="block text-xs font-bold uppercase text-helios-slate mb-1">Type</label>
|
||||||
<select
|
<select
|
||||||
value={newType}
|
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"
|
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>
|
{TARGET_TYPES.map((type) => (
|
||||||
<option value="gotify">Gotify</option>
|
<option key={type} value={type}>
|
||||||
<option value="webhook">Generic Webhook</option>
|
{type === "discord" ? "Discord Webhook" : type === "gotify" ? "Gotify" : "Generic Webhook"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +213,7 @@ export default function NotificationSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Events</label>
|
<label className="block text-xs font-bold uppercase text-helios-slate mb-2">Events</label>
|
||||||
<div className="flex gap-4 flex-wrap">
|
<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">
|
<label key={evt} className="flex items-center gap-2 text-sm text-helios-ink cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -219,50 +233,60 @@ export default function NotificationSettings() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{loading ? (
|
||||||
{targets.map(target => (
|
<div className="text-sm text-helios-slate animate-pulse">Loading targets…</div>
|
||||||
<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="space-y-3">
|
||||||
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
|
{targets.map(target => (
|
||||||
<Zap size={18} />
|
<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>
|
<div className="flex items-center gap-4">
|
||||||
<div>
|
<div className="p-2 bg-helios-surface-soft rounded-lg text-helios-slate">
|
||||||
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
|
<Zap size={18} />
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
</div>
|
||||||
<span className="text-[10px] uppercase font-bold tracking-wider text-helios-slate bg-helios-surface-soft px-1.5 rounded">
|
<div>
|
||||||
{target.target_type}
|
<h3 className="font-bold text-sm text-helios-ink">{target.name}</h3>
|
||||||
</span>
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-helios-slate truncate max-w-[200px]">
|
<span className="text-[10px] uppercase font-bold tracking-wider text-helios-slate bg-helios-surface-soft px-1.5 rounded">
|
||||||
{target.endpoint_url}
|
{target.target_type}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-helios-slate truncate max-w-[200px]">{target.endpoint_url}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
<button
|
</div>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{targets.length === 0 && !loading && (
|
<ConfirmDialog
|
||||||
<div className="text-center py-8 text-helios-slate text-sm">
|
open={pendingDeleteId !== null}
|
||||||
No notification targets configured.
|
title="Remove notification target"
|
||||||
</div>
|
description="Remove this notification target?"
|
||||||
)}
|
confirmLabel="Remove"
|
||||||
</div>
|
tone="danger"
|
||||||
|
onClose={() => setPendingDeleteId(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (pendingDeleteId === null) return;
|
||||||
|
await handleDelete(pendingDeleteId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiJson, isApiError } from "../lib/api";
|
||||||
import { Activity, Cpu, HardDrive, Clock, Layers } from 'lucide-react';
|
import { Activity, Cpu, HardDrive, Clock, Layers } from "lucide-react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface SystemResources {
|
interface SystemResources {
|
||||||
cpu_percent: number;
|
cpu_percent: number;
|
||||||
@@ -20,43 +20,84 @@ interface SystemSettings {
|
|||||||
monitoring_poll_interval: number;
|
monitoring_poll_interval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_INTERVAL_MS = 500;
|
||||||
|
const MAX_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
export default function ResourceMonitor() {
|
export default function ResourceMonitor() {
|
||||||
const [stats, setStats] = useState<SystemResources | null>(null);
|
const [stats, setStats] = useState<SystemResources | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pollInterval, setPollInterval] = useState<number>(2000);
|
const [pollInterval, setPollInterval] = useState<number>(2000);
|
||||||
|
|
||||||
// Fetch settings once on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch('/api/settings/system')
|
void apiJson<SystemSettings>("/api/settings/system")
|
||||||
.then(res => res.json())
|
.then((data) => {
|
||||||
.then((data: SystemSettings) => {
|
|
||||||
const seconds = Number(data?.monitoring_poll_interval);
|
const seconds = Number(data?.monitoring_poll_interval);
|
||||||
if (!Number.isFinite(seconds)) return;
|
if (!Number.isFinite(seconds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const intervalMs = Math.round(seconds * 1000);
|
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(() => {
|
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 {
|
try {
|
||||||
const res = await apiFetch('/api/system/resources');
|
const data = await apiJson<SystemResources>("/api/system/resources");
|
||||||
if (res.ok) {
|
if (!mounted) {
|
||||||
const data = await res.json();
|
return;
|
||||||
setStats(data);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
setError('Failed to fetch resources');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
setStats(data);
|
||||||
setError('Connection error');
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(isApiError(err) ? err.message : "Connection error");
|
||||||
|
} finally {
|
||||||
|
schedule(pollInterval);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetchStats();
|
const schedule = (delayMs: number) => {
|
||||||
const interval = setInterval(fetchStats, pollInterval);
|
if (!mounted) {
|
||||||
return () => clearInterval(interval);
|
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]);
|
}, [pollInterval]);
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
const formatUptime = (seconds: number) => {
|
||||||
@@ -70,35 +111,32 @@ export default function ResourceMonitor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getUsageColor = (percent: number) => {
|
const getUsageColor = (percent: number) => {
|
||||||
if (percent > 90) return 'text-red-500 bg-red-500/10';
|
if (percent > 90) return "text-red-500 bg-red-500/10";
|
||||||
if (percent > 70) return 'text-yellow-500 bg-yellow-500/10';
|
if (percent > 70) return "text-yellow-500 bg-yellow-500/10";
|
||||||
return 'text-green-500 bg-green-500/10';
|
return "text-green-500 bg-green-500/10";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBarColor = (percent: number) => {
|
const getBarColor = (percent: number) => {
|
||||||
if (percent > 90) return 'bg-red-500';
|
if (percent > 90) return "bg-red-500";
|
||||||
if (percent > 70) return 'bg-yellow-500';
|
if (percent > 70) return "bg-yellow-500";
|
||||||
return 'bg-green-500';
|
return "bg-green-500";
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!stats) return (
|
if (!stats) {
|
||||||
<div className={`p-6 rounded-2xl bg-white/5 border border-white/10 h-48 flex items-center justify-center ${error ? "" : "animate-pulse"}`}>
|
return (
|
||||||
<div className="text-center">
|
<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-sm ${error ? "text-red-400" : "text-white/40"}`}>
|
<div className="text-center" aria-live="polite">
|
||||||
{error ? "Unable to load system stats." : "Loading system stats..."}
|
<div className={`text-sm ${error ? "text-red-400" : "text-white/40"}`}>
|
||||||
</div>
|
{error ? "Unable to load system stats." : "Loading system stats..."}
|
||||||
{error && (
|
|
||||||
<div className="text-[10px] text-white/40 mt-2">
|
|
||||||
{error} Retrying automatically...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{error && <div className="text-[10px] text-white/40 mt-2">{error} Retrying automatically...</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-5 gap-3" aria-live="polite">
|
||||||
{/* CPU Usage */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -126,7 +164,6 @@ export default function ResourceMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Memory Usage */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -155,7 +192,6 @@ export default function ResourceMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Active Jobs */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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">
|
<div className="flex items-center gap-2 text-white/60 text-sm font-medium">
|
||||||
<Layers size={16} /> Active Jobs
|
<Layers size={16} /> Active Jobs
|
||||||
</div>
|
</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}
|
{stats.active_jobs} / {stats.concurrent_limit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-1 h-8 mt-2">
|
<div className="flex items-end gap-1 h-8 mt-2">
|
||||||
{/* Visual representation of job slots */}
|
|
||||||
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* GPU Usage */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -198,9 +233,7 @@ export default function ResourceMonitor() {
|
|||||||
{stats.gpu_utilization.toFixed(1)}%
|
{stats.gpu_utilization.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-white/10 text-white/40">
|
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-white/10 text-white/40">N/A</span>
|
||||||
N/A
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -219,7 +252,6 @@ export default function ResourceMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Uptime */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -232,12 +264,8 @@ export default function ResourceMonitor() {
|
|||||||
</div>
|
</div>
|
||||||
<Activity size={14} className="text-green-500 animate-pulse" />
|
<Activity size={14} className="text-green-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white/90">
|
<div className="text-2xl font-bold text-white/90">{formatUptime(stats.uptime_seconds)}</div>
|
||||||
{formatUptime(stats.uptime_seconds)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Clock, Plus, Trash2, Calendar } from "lucide-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 {
|
interface ScheduleWindow {
|
||||||
id: number;
|
id: number;
|
||||||
start_time: string; // HH:MM
|
start_time: string;
|
||||||
end_time: string; // HH:MM
|
end_time: string;
|
||||||
days_of_week: string; // JSON array of ints
|
days_of_week: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,12 +16,14 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|||||||
|
|
||||||
export default function ScheduleSettings() {
|
export default function ScheduleSettings() {
|
||||||
const [windows, setWindows] = useState<ScheduleWindow[]>([]);
|
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 [newStart, setNewStart] = useState("00:00");
|
||||||
const [newEnd, setNewEnd] = useState("08:00");
|
const [newEnd, setNewEnd] = useState("08:00");
|
||||||
const [selectedDays, setSelectedDays] = useState<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
const [selectedDays, setSelectedDays] = useState<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
@@ -27,13 +31,12 @@ export default function ScheduleSettings() {
|
|||||||
|
|
||||||
const fetchSchedule = async () => {
|
const fetchSchedule = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/schedule");
|
const data = await apiJson<ScheduleWindow[]>("/api/settings/schedule");
|
||||||
if (res.ok) {
|
setWindows(data);
|
||||||
const data = await res.json();
|
setError(null);
|
||||||
setWindows(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
const message = isApiError(e) ? e.message : "Failed to load schedule windows";
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -42,32 +45,37 @@ export default function ScheduleSettings() {
|
|||||||
const handleAdd = async (e: React.FormEvent) => {
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/schedule", {
|
await apiAction("/api/settings/schedule", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
start_time: newStart,
|
start_time: newStart,
|
||||||
end_time: newEnd,
|
end_time: newEnd,
|
||||||
days_of_week: selectedDays,
|
days_of_week: selectedDays,
|
||||||
enabled: true
|
enabled: true,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
setShowForm(false);
|
||||||
setShowForm(false);
|
setError(null);
|
||||||
await fetchSchedule();
|
await fetchSchedule();
|
||||||
}
|
showToast({ kind: "success", title: "Schedule", message: "Schedule added." });
|
||||||
} catch (e) {
|
} 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) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm("Remove this schedule?")) return;
|
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/settings/schedule/${id}`, { method: "DELETE" });
|
await apiAction(`/api/settings/schedule/${id}`, { method: "DELETE" });
|
||||||
|
setError(null);
|
||||||
await fetchSchedule();
|
await fetchSchedule();
|
||||||
|
showToast({ kind: "success", title: "Schedule", message: "Schedule removed." });
|
||||||
} catch (e) {
|
} 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 (
|
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 justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||||
@@ -108,7 +116,15 @@ export default function ScheduleSettings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium flex items-center gap-2">
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
@@ -157,10 +173,11 @@ export default function ScheduleSettings() {
|
|||||||
key={day}
|
key={day}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDay(idx)}
|
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-solar text-helios-main"
|
||||||
: "bg-helios-surface border border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
: "bg-helios-surface border border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
@@ -192,7 +209,7 @@ export default function ScheduleSettings() {
|
|||||||
{DAYS.map((day, idx) => {
|
{DAYS.map((day, idx) => {
|
||||||
const active = parseDays(win.days_of_week).includes(idx);
|
const active = parseDays(win.days_of_week).includes(idx);
|
||||||
return (
|
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}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -200,14 +217,28 @@ export default function ScheduleSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { apiAction, apiJson, isApiError } from '../lib/api';
|
||||||
|
import { showToast } from '../lib/toast';
|
||||||
|
|
||||||
interface ConfigState {
|
interface ConfigState {
|
||||||
// Auth
|
// Auth
|
||||||
@@ -46,9 +48,9 @@ export default function SetupWizard() {
|
|||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [_success, _setSuccess] = useState(false);
|
|
||||||
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
|
const [hardware, setHardware] = useState<HardwareInfo | null>(null);
|
||||||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
const scanIntervalRef = useRef<number | null>(null);
|
const scanIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const [config, setConfig] = useState<ConfigState>({
|
const [config, setConfig] = useState<ConfigState>({
|
||||||
@@ -69,14 +71,17 @@ export default function SetupWizard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSetupDefaults = async () => {
|
const loadSetupDefaults = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/setup/status', { credentials: 'same-origin' });
|
const data = await apiJson<{ enable_telemetry?: boolean }>('/api/setup/status');
|
||||||
if (!res.ok) return;
|
const telemetryEnabled = data.enable_telemetry;
|
||||||
const data = await res.json();
|
if (typeof telemetryEnabled === 'boolean') {
|
||||||
if (typeof data.enable_telemetry === 'boolean') {
|
setConfig(prev => ({ ...prev, enable_telemetry: telemetryEnabled }));
|
||||||
setConfig(prev => ({ ...prev, enable_telemetry: data.enable_telemetry }));
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 () => {
|
const handleNext = async () => {
|
||||||
if (step === 1 && (!config.username || !config.password)) {
|
if (step === 1 && (!config.username || !config.password)) {
|
||||||
setError("Please fill in both username and password.");
|
setError("Please fill in both username and password.");
|
||||||
@@ -103,16 +115,11 @@ export default function SetupWizard() {
|
|||||||
if (!hardware) {
|
if (!hardware) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/system/hardware', {
|
const data = await apiJson<HardwareInfo>('/api/system/hardware');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Hardware detection failed (${res.status})`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setHardware(data);
|
setHardware(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Hardware detection failed", e);
|
const message = isApiError(e) ? e.message : "Hardware detection failed";
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -131,58 +138,47 @@ export default function SetupWizard() {
|
|||||||
|
|
||||||
const handleBack = () => setStep(s => Math.max(s - 1, 1));
|
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 () => {
|
const pollScanStatus = async () => {
|
||||||
if (scanIntervalRef.current !== null) {
|
clearScanPolling();
|
||||||
window.clearInterval(scanIntervalRef.current);
|
|
||||||
scanIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
scanIntervalRef.current = window.setInterval(async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/scan/status', {
|
const data = await apiJson<ScanStatus>('/api/scan/status');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(await res.text());
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setScanStatus(data);
|
setScanStatus(data);
|
||||||
|
setScanError(null);
|
||||||
if (!data.is_running) {
|
if (!data.is_running) {
|
||||||
if (scanIntervalRef.current !== null) {
|
clearScanPolling();
|
||||||
window.clearInterval(scanIntervalRef.current);
|
|
||||||
scanIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Polling failed", e);
|
const message = isApiError(e) ? e.message : "Scan status unavailable";
|
||||||
setError("Scan status unavailable. Please refresh and try again.");
|
setScanError(message);
|
||||||
if (scanIntervalRef.current !== null) {
|
clearScanPolling();
|
||||||
window.clearInterval(scanIntervalRef.current);
|
|
||||||
scanIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await poll();
|
||||||
|
scanIntervalRef.current = window.setInterval(() => {
|
||||||
|
void poll();
|
||||||
}, 1000);
|
}, 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 = () => {
|
const addDirectory = () => {
|
||||||
if (dirInput && !config.directories.includes(dirInput)) {
|
if (dirInput && !config.directories.includes(dirInput)) {
|
||||||
setConfig(prev => ({
|
setConfig(prev => ({
|
||||||
@@ -204,27 +200,22 @@ export default function SetupWizard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/setup/complete', {
|
await apiAction('/api/setup/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(config)
|
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
|
setStep(5); // Move to Scan Progress
|
||||||
|
setScanStatus(null);
|
||||||
|
setScanError(null);
|
||||||
await startScan();
|
await startScan();
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.message || "Failed to save configuration");
|
const message = isApiError(err) ? err.message : "Failed to save configuration";
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -472,6 +463,7 @@ export default function SetupWizard() {
|
|||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="space-y-8 py-10 text-center"
|
className="space-y-8 py-10 text-center"
|
||||||
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="relative">
|
<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>
|
<p className="text-sm text-helios-slate">Building your transcoding queue. This might take a moment.</p>
|
||||||
</div>
|
</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 && (
|
{scanStatus && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-helios-slate">
|
<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}
|
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"
|
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} />}
|
{!loading && <ArrowRight size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Gauge,
|
Gauge,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
Timer
|
Timer,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiFetch } from "../lib/api";
|
import { apiJson, isApiError } from "../lib/api";
|
||||||
|
|
||||||
interface AggregatedStats {
|
interface AggregatedStats {
|
||||||
total_input_bytes: number;
|
total_input_bytes: number;
|
||||||
@@ -47,6 +48,7 @@ export default function StatsCharts() {
|
|||||||
const [dailyStats, setDailyStats] = useState<DailyStats[]>([]);
|
const [dailyStats, setDailyStats] = useState<DailyStats[]>([]);
|
||||||
const [detailedStats, setDetailedStats] = useState<DetailedStats[]>([]);
|
const [detailedStats, setDetailedStats] = useState<DetailedStats[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchAllStats();
|
void fetchAllStats();
|
||||||
@@ -54,17 +56,17 @@ export default function StatsCharts() {
|
|||||||
|
|
||||||
const fetchAllStats = async () => {
|
const fetchAllStats = async () => {
|
||||||
try {
|
try {
|
||||||
const [aggRes, dailyRes, detailedRes] = await Promise.all([
|
const [aggData, dailyData, detailedData] = await Promise.all([
|
||||||
apiFetch("/api/stats/aggregated"),
|
apiJson<AggregatedStats>("/api/stats/aggregated"),
|
||||||
apiFetch("/api/stats/daily"),
|
apiJson<DailyStats[]>("/api/stats/daily"),
|
||||||
apiFetch("/api/stats/detailed")
|
apiJson<DetailedStats[]>("/api/stats/detailed")
|
||||||
]);
|
]);
|
||||||
|
setStats(aggData);
|
||||||
if (aggRes.ok) setStats(await aggRes.json());
|
setDailyStats(dailyData);
|
||||||
if (dailyRes.ok) setDailyStats(await dailyRes.json());
|
setDetailedStats(detailedData);
|
||||||
if (detailedRes.ok) setDetailedStats(await detailedRes.json());
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch stats", e);
|
setError(isApiError(e) ? e.message : "Failed to fetch statistics");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,15 @@ export default function StatsCharts() {
|
|||||||
// Find max for bar chart scaling
|
// Find max for bar chart scaling
|
||||||
const maxDailyJobs = Math.max(...dailyStats.map(d => d.jobs_completed), 1);
|
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="p-6 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -143,7 +153,14 @@ export default function StatsCharts() {
|
|||||||
</div>
|
</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-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`}>
|
<div className={`p-2 rounded-lg ${colorClass} bg-opacity-10`}>
|
||||||
<Icon size={18} className={colorClass} />
|
<Icon size={18} className={colorClass} />
|
||||||
@@ -157,6 +174,11 @@ export default function StatsCharts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Main Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Activity, Save } from "lucide-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 {
|
interface SystemSettingsPayload {
|
||||||
monitoring_poll_interval: number;
|
monitoring_poll_interval: number;
|
||||||
@@ -20,13 +21,11 @@ export default function SystemSettings() {
|
|||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/system");
|
const data = await apiJson<SystemSettingsPayload>("/api/settings/system");
|
||||||
if (!res.ok) throw new Error("Failed to load settings");
|
|
||||||
const data = await res.json();
|
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
|
setError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Unable to load system settings.");
|
setError(isApiError(err) ? err.message : "Unable to load system settings.");
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,15 +38,17 @@ export default function SystemSettings() {
|
|||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/system", {
|
await apiAction("/api/settings/system", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save settings");
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
showToast({ kind: "success", title: "System", message: "System settings saved." });
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,7 @@ export default function SystemSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="flex items-center justify-between pb-2 border-b border-helios-line/10">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">System Monitoring</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-500 rounded-xl text-sm font-semibold">
|
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-500 rounded-xl text-sm font-semibold">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
@@ -103,7 +102,9 @@ export default function SystemSettings() {
|
|||||||
{settings.monitoring_poll_interval.toFixed(1)}s
|
{settings.monitoring_poll_interval.toFixed(1)}s
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-helios-line/10">
|
<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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Activity, X, Zap, CheckCircle2, AlertTriangle, Database } from "lucide-react";
|
import { Activity, X, Zap, CheckCircle2, AlertTriangle, Database } from "lucide-react";
|
||||||
import { apiFetch } from "../lib/api";
|
import { useSharedStats } from "../lib/statsStore";
|
||||||
|
|
||||||
interface Stats {
|
function focusables(root: HTMLElement): HTMLElement[] {
|
||||||
active: number;
|
const selector = [
|
||||||
concurrent_limit: number;
|
"a[href]",
|
||||||
completed: number;
|
"button:not([disabled])",
|
||||||
failed: number;
|
"input:not([disabled])",
|
||||||
total: number;
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"[tabindex]:not([tabindex='-1'])",
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(selector));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemStatus() {
|
export default function SystemStatus() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const { stats, error } = useSharedStats();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const layoutId = useId();
|
const layoutId = useId();
|
||||||
|
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
if (!isExpanded) {
|
||||||
try {
|
return;
|
||||||
const res = await apiFetch("/api/stats");
|
}
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
lastFocusedRef.current = document.activeElement as HTMLElement | null;
|
||||||
setStats({
|
closeRef.current?.focus();
|
||||||
active: data.active || 0,
|
|
||||||
concurrent_limit: data.concurrent_limit || 1,
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
completed: data.completed || 0,
|
if (event.key === "Escape") {
|
||||||
failed: data.failed || 0,
|
event.preventDefault();
|
||||||
total: data.total || 0,
|
setIsExpanded(false);
|
||||||
});
|
return;
|
||||||
setError(null);
|
}
|
||||||
} else {
|
if (event.key !== "Tab") {
|
||||||
setError("Status unavailable");
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch system status", e);
|
const root = modalRef.current;
|
||||||
setError("Status unavailable");
|
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();
|
document.addEventListener("keydown", onKeyDown);
|
||||||
const interval = setInterval(fetchStats, 5000);
|
return () => {
|
||||||
return () => clearInterval(interval);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
}, []);
|
lastFocusedRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
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">
|
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">
|
||||||
{error ? "Status Unavailable" : "Loading Status..."}
|
{error ? "Status Unavailable" : "Loading Status..."}
|
||||||
</span>
|
</span>
|
||||||
@@ -62,7 +89,6 @@ export default function SystemStatus() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Compact Sidebar View */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={layoutId}
|
layoutId={layoutId}
|
||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
@@ -73,13 +99,13 @@ export default function SystemStatus() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="relative flex h-2 w-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={`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={`relative inline-flex rounded-full h-2 w-2 ${isActive ? "bg-status-success" : "bg-helios-slate"}`}></span>
|
||||||
</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>
|
||||||
<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'}`}>
|
<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'}
|
{isActive ? "ONLINE" : "IDLE"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,18 +113,16 @@ export default function SystemStatus() {
|
|||||||
<div className="flex items-end justify-between text-helios-ink">
|
<div className="flex items-end justify-between text-helios-ink">
|
||||||
<span className="text-xs font-medium opacity-80">Active Jobs</span>
|
<span className="text-xs font-medium opacity-80">Active Jobs</span>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<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}
|
{stats.active}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-helios-slate">
|
<span className="text-xs text-helios-slate">/ {stats.concurrent_limit}</span>
|
||||||
/ {stats.concurrent_limit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
|
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
||||||
@@ -108,11 +132,9 @@ export default function SystemStatus() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Expanded Modal View */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -120,13 +142,16 @@ export default function SystemStatus() {
|
|||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
{/* Modal Card */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="system-status-title"
|
||||||
layoutId={layoutId}
|
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()}
|
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="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">
|
<div className="p-8 relative">
|
||||||
@@ -136,42 +161,38 @@ export default function SystemStatus() {
|
|||||||
<Activity className="text-helios-solar" size={24} />
|
<Activity className="text-helios-solar" size={24} />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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">
|
<span className="text-xs font-medium text-helios-slate uppercase tracking-wide">
|
||||||
{isActive ? 'Engine Running' : 'Engine Idle'}
|
{isActive ? "Engine Running" : "Engine Idle"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={closeRef}
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
className="p-2 hover:bg-helios-surface-soft rounded-full text-helios-slate hover:text-helios-ink transition-colors"
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Metrics Grid */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
<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">
|
<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" />
|
<Zap size={20} className="text-helios-solar opacity-80" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Concurrency</span>
|
<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">
|
<div className="flex items-baseline justify-center gap-1 mt-1">
|
||||||
<span className="text-3xl font-bold text-helios-ink">
|
<span className="text-3xl font-bold text-helios-ink">{stats.active}</span>
|
||||||
{stats.active}
|
<span className="text-sm font-medium text-helios-slate opacity-60">/ {stats.concurrent_limit}</span>
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-helios-slate opacity-60">
|
|
||||||
/ {stats.concurrent_limit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Big Progress Bar */}
|
|
||||||
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
|
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,17 +202,12 @@ export default function SystemStatus() {
|
|||||||
<Database size={20} className="text-blue-400 opacity-80" />
|
<Database size={20} className="text-blue-400 opacity-80" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Total Jobs</span>
|
<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">
|
<span className="text-3xl font-bold text-helios-ink mt-1">{stats.total}</span>
|
||||||
{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 className="text-[10px] text-helios-slate mt-1 px-2 py-0.5 bg-helios-line/10 rounded-md">Lifetime</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary Metrics Row */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<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">
|
<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" />
|
<CheckCircle2 size={16} className="text-status-success mb-1" />
|
||||||
@@ -213,9 +229,7 @@ export default function SystemStatus() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-helios-line/10 text-center">
|
<div className="mt-6 pt-6 border-t border-helios-line/10 text-center">
|
||||||
<p className="text-xs text-helios-slate/60">
|
<p className="text-xs text-helios-slate/60">System metrics update automatically while this tab is active.</p>
|
||||||
System metrics update automatically every 5 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
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[]) {
|
function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
@@ -44,13 +45,11 @@ export default function TranscodeSettings() {
|
|||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/transcode");
|
const data = await apiJson<TranscodeSettingsPayload>("/api/settings/transcode");
|
||||||
if (!res.ok) throw new Error("Failed to load settings");
|
|
||||||
const data = await res.json();
|
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
|
setError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Unable to load current settings.");
|
setError(isApiError(err) ? err.message : "Unable to load current settings.");
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -63,15 +62,17 @@ export default function TranscodeSettings() {
|
|||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/transcode", {
|
await apiAction("/api/settings/transcode", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save settings");
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
showToast({ kind: "success", title: "Transcoding", message: "Transcode settings saved." });
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FolderOpen, Trash2, Plus, Folder, Play } from "lucide-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 {
|
interface WatchDir {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,16 +15,17 @@ export default function WatchFolders() {
|
|||||||
const [path, setPath] = useState("");
|
const [path, setPath] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingRemoveId, setPendingRemoveId] = useState<number | null>(null);
|
||||||
|
|
||||||
const fetchDirs = async () => {
|
const fetchDirs = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/watch-dirs");
|
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
|
||||||
if (res.ok) {
|
setDirs(data);
|
||||||
const data = await res.json();
|
setError(null);
|
||||||
setDirs(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch watch dirs", e);
|
const message = isApiError(e) ? e.message : "Failed to fetch watch directories";
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -34,12 +37,16 @@ export default function WatchFolders() {
|
|||||||
|
|
||||||
const triggerScan = async () => {
|
const triggerScan = async () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
|
setError(null);
|
||||||
try {
|
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) {
|
} 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 {
|
} finally {
|
||||||
setTimeout(() => setScanning(false), 2000);
|
window.setTimeout(() => setScanning(false), 1200);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,39 +55,40 @@ export default function WatchFolders() {
|
|||||||
if (!path.trim()) return;
|
if (!path.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/settings/watch-dirs", {
|
await apiAction("/api/settings/watch-dirs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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("");
|
||||||
setPath("");
|
setError(null);
|
||||||
await fetchDirs();
|
await fetchDirs();
|
||||||
}
|
showToast({ kind: "success", title: "Watch Folders", message: "Folder added." });
|
||||||
} catch (e) {
|
} 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) => {
|
const removeDir = async (id: number) => {
|
||||||
if (!confirm("Stop watching this folder?")) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/settings/watch-dirs/${id}`, {
|
await apiAction(`/api/settings/watch-dirs/${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
setError(null);
|
||||||
if (res.ok) {
|
await fetchDirs();
|
||||||
await fetchDirs();
|
showToast({ kind: "success", title: "Watch Folders", message: "Folder removed." });
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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 (
|
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 justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
<div className="p-2 bg-helios-solar/10 rounded-lg">
|
||||||
@@ -92,7 +100,7 @@ export default function WatchFolders() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={triggerScan}
|
onClick={() => void triggerScan()}
|
||||||
disabled={scanning}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<form onSubmit={addDir} className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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"
|
title="Stop watching"
|
||||||
>
|
>
|
||||||
@@ -156,6 +170,19 @@ export default function WatchFolders() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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 "../styles/global.css";
|
||||||
import { ViewTransitions } from "astro:transitions";
|
import { ViewTransitions } from "astro:transitions";
|
||||||
|
import ToastRegion from "../components/ui/ToastRegion";
|
||||||
|
import AuthGuard from "../components/AuthGuard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,6 +24,8 @@ const { title } = Astro.props;
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<slot />
|
<slot />
|
||||||
|
<ToastRegion client:load />
|
||||||
|
<AuthGuard client:load />
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
try {
|
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
|
// Run on initial load
|
||||||
initTheme();
|
initTheme();
|
||||||
checkAuth();
|
|
||||||
|
|
||||||
// Run on view transition navigation
|
// Run on view transition navigation
|
||||||
document.addEventListener('astro:after-swap', () => {
|
document.addEventListener('astro:after-swap', () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
checkAuth();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</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.
|
* Authenticated fetch utility using cookie auth.
|
||||||
*/
|
*/
|
||||||
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|
||||||
if (!headers.has('Content-Type') && typeof options.body === 'string') {
|
if (!headers.has("Content-Type") && typeof options.body === "string") {
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -16,7 +97,7 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
|||||||
if (options.signal.aborted) {
|
if (options.signal.aborted) {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
} else {
|
} 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, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: options.credentials ?? 'same-origin',
|
credentials: options.credentials ?? "same-origin",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
if (typeof window !== 'undefined') {
|
const path = window.location.pathname;
|
||||||
const path = window.location.pathname;
|
const isAuthPage = path.startsWith("/login") || path.startsWith("/setup");
|
||||||
const isAuthPage = path.startsWith('/login') || path.startsWith('/setup');
|
if (!isAuthPage) {
|
||||||
if (!isAuthPage) {
|
window.location.href = "/login";
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +123,34 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function apiJson<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
* Helper for GET requests
|
const response = await apiFetch(url, options);
|
||||||
*/
|
if (!response.ok) {
|
||||||
export async function apiGet(url: string): Promise<Response> {
|
throw await toApiError(url, response);
|
||||||
return apiFetch(url);
|
}
|
||||||
|
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> {
|
export async function apiGet<T>(url: string): Promise<T> {
|
||||||
return apiFetch(url, {
|
return apiJson<T>(url);
|
||||||
method: 'POST',
|
}
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
|
/**
|
||||||
|
* 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>
|
</Layout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
fetch("/api/setup/status")
|
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||||
.then((res) => res.json())
|
|
||||||
|
void apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data?.setup_required) {
|
if (data?.setup_required) {
|
||||||
window.location.href = "/setup";
|
window.location.href = "/setup";
|
||||||
@@ -82,23 +83,22 @@ import { ArrowRight } from "lucide-react";
|
|||||||
errorMsg?.classList.add('hidden');
|
errorMsg?.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
await apiAction('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
window.location.href = '/';
|
||||||
if (res.ok) {
|
|
||||||
window.location.href = '/';
|
|
||||||
} else {
|
|
||||||
errorMsg?.classList.remove('hidden');
|
|
||||||
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Invalid username or password";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error("Login failed", err);
|
||||||
errorMsg?.classList.remove('hidden');
|
errorMsg?.classList.remove('hidden');
|
||||||
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Connection failed";
|
const message = isApiError(err)
|
||||||
|
? err.status === 401
|
||||||
|
? "Invalid username or password"
|
||||||
|
: err.message
|
||||||
|
: "Connection failed";
|
||||||
|
if (errorMsg) errorMsg.querySelector('span')!.textContent = message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import SetupWizard from "../components/SetupWizard";
|
|||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { apiJson } from "../lib/api";
|
||||||
|
|
||||||
// If setup is already done, redirect to dashboard
|
// If setup is already done, redirect to dashboard
|
||||||
fetch("/api/setup/status")
|
apiJson<{ setup_required: boolean }>("/api/setup/status")
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.setup_required) {
|
if (!data.setup_required) {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|||||||
Reference in New Issue
Block a user