mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
release: v0.2.2-stable
This commit is contained in:
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -29,6 +29,7 @@ name = "alchemist"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -39,6 +40,7 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"notify",
|
||||
"num_cpus",
|
||||
"rand",
|
||||
"rayon",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
@@ -128,6 +130,18 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -306,6 +320,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1667,6 +1690,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alchemist"
|
||||
version = "0.1.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
|
||||
@@ -39,3 +39,5 @@ reqwest = { version = "0.12", features = ["json"] }
|
||||
rust-embed = { version = "8", features = ["axum"] }
|
||||
mime_guess = "2.0"
|
||||
async-trait = "0.1"
|
||||
argon2 = "0.5.3"
|
||||
rand = "0.8"
|
||||
|
||||
19
migrations/20240109120000_add_auth_tables.sql
Normal file
19
migrations/20240109120000_add_auth_tables.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Add users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Add sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for session cleanup and lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
67
src/db.rs
67
src/db.rs
@@ -509,4 +509,71 @@ impl Db {
|
||||
.await?;
|
||||
Ok(row.map(|r| r.0))
|
||||
}
|
||||
|
||||
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn has_users(&self) -> Result<bool> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0 > 0)
|
||||
}
|
||||
|
||||
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: DateTime<Utc>) -> Result<()> {
|
||||
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
|
||||
.bind(token)
|
||||
.bind(user_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_session(&self, token: &str) -> Result<Option<Session>> {
|
||||
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP")
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn cleanup_sessions(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Auth related structs
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
|
||||
pub struct Session {
|
||||
pub token: String,
|
||||
pub user_id: i64,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@@ -39,14 +39,12 @@ async fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
// Startup Banner
|
||||
info!("╔═══════════════════════════════════════════════════════════════╗");
|
||||
info!("║ ALCHEMIST ║");
|
||||
info!("║ Video Transcoding Automation ║");
|
||||
info!(
|
||||
"║ Version {} ║",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
info!("╚═══════════════════════════════════════════════════════════════╝");
|
||||
info!(" ______ __ ______ __ __ ______ __ __ __ ______ ______ ");
|
||||
info!("/\\ __ \\ /\\ \\ /\\ ___\\ /\\ \\_\\ \\ /\\ ___\\ /\\ \"-./ \\ /\\ \\ /\\ ___\\ /\\__ _\\");
|
||||
info!("\\ \\ __ \\ \\ \\ \\____ \\ \\ \\____ \\ \\ __ \\ \\ \\ __\\ \\ \\ \\-./\\ \\ \\ \\ \\ \\ \\___ \\ \\/_/\\ \\/");
|
||||
info!(" \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_\\ \\_\\ \\ \\_____\\ \\ \\_\\ \\ \\_\\ \\ \\_\\ \\/\\_____\\ \\ \\_\\");
|
||||
info!(" \\/_/\\/_/ \\/_____/ \\/_____/ \\/_/\\/_/ \\/_____/ \\/_/ \\/_/ \\/_/ \\/_____/ \\/_/");
|
||||
info!("");
|
||||
info!("");
|
||||
info!("System Information:");
|
||||
info!(
|
||||
|
||||
170
src/server.rs
170
src/server.rs
@@ -23,6 +23,14 @@ use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tracing::info;
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::Rng;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "web/dist/"]
|
||||
@@ -34,7 +42,7 @@ pub struct AppState {
|
||||
pub agent: Arc<Agent>,
|
||||
pub transcoder: Arc<Transcoder>,
|
||||
pub tx: broadcast::Sender<AlchemistEvent>,
|
||||
pub setup_required: bool,
|
||||
pub setup_required: Arc<AtomicBool>,
|
||||
pub start_time: Instant,
|
||||
}
|
||||
|
||||
@@ -52,7 +60,7 @@ pub async fn run_server(
|
||||
agent,
|
||||
transcoder,
|
||||
tx,
|
||||
setup_required,
|
||||
setup_required: Arc::new(AtomicBool::new(setup_required)),
|
||||
start_time: std::time::Instant::now(),
|
||||
});
|
||||
|
||||
@@ -80,6 +88,7 @@ pub async fn run_server(
|
||||
// Setup Routes
|
||||
.route("/api/setup/status", get(setup_status_handler))
|
||||
.route("/api/setup/complete", post(setup_complete_handler))
|
||||
.route("/api/auth/login", post(login_handler))
|
||||
.route(
|
||||
"/api/ui/preferences",
|
||||
get(get_preferences_handler).post(update_preferences_handler),
|
||||
@@ -103,7 +112,7 @@ pub async fn run_server(
|
||||
|
||||
async fn setup_status_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
axum::Json(serde_json::json!({
|
||||
"setup_required": state.setup_required
|
||||
"setup_required": state.setup_required.load(Ordering::Relaxed)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -163,6 +172,8 @@ async fn update_transcode_settings_handler(
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetupConfig {
|
||||
username: String,
|
||||
password: String,
|
||||
size_reduction_threshold: f64,
|
||||
min_file_size_mb: u64,
|
||||
concurrent_jobs: usize,
|
||||
@@ -174,42 +185,64 @@ async fn setup_complete_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<SetupConfig>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.setup_required {
|
||||
if !state.setup_required.load(Ordering::Relaxed) {
|
||||
return (StatusCode::FORBIDDEN, "Setup already completed").into_response();
|
||||
}
|
||||
|
||||
// Create config object
|
||||
let mut config = Config::default();
|
||||
config.transcode.concurrent_jobs = payload.concurrent_jobs;
|
||||
config.transcode.size_reduction_threshold = payload.size_reduction_threshold;
|
||||
config.transcode.min_file_size_mb = payload.min_file_size_mb;
|
||||
config.hardware.allow_cpu_encoding = payload.allow_cpu_encoding;
|
||||
config.scanner.directories = payload.directories;
|
||||
// Create User
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = match argon2.hash_password(payload.password.as_bytes(), &salt) {
|
||||
Ok(h) => h.to_string(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Hashing failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let user_id = match state.db.create_user(&payload.username, &password_hash).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
// Create Initial Session
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let expires_at = Utc::now() + chrono::Duration::days(30);
|
||||
|
||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create session: {}", e)).into_response();
|
||||
}
|
||||
|
||||
// Save Config
|
||||
let mut config_lock = state.config.write().await;
|
||||
config_lock.transcode.concurrent_jobs = payload.concurrent_jobs;
|
||||
config_lock.transcode.size_reduction_threshold = payload.size_reduction_threshold;
|
||||
config_lock.transcode.min_file_size_mb = payload.min_file_size_mb;
|
||||
config_lock.hardware.allow_cpu_encoding = payload.allow_cpu_encoding;
|
||||
config_lock.scanner.directories = payload.directories;
|
||||
|
||||
// Serialize to TOML
|
||||
let toml_string = match toml::to_string_pretty(&config) {
|
||||
let toml_string = match toml::to_string_pretty(&*config_lock) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize config: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
// Write to file
|
||||
if let Err(e) = std::fs::write("config.toml", toml_string) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write config.toml: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write config.toml: {}", e)).into_response();
|
||||
}
|
||||
|
||||
// Update Setup State (Hot Reload)
|
||||
state.setup_required.store(false, Ordering::Relaxed);
|
||||
|
||||
// Start Scan (optional, but good for UX)
|
||||
let dirs = config_lock.scanner.directories.iter().map(std::path::PathBuf::from).collect();
|
||||
let _ = state.agent.scan_and_enqueue(dirs).await;
|
||||
|
||||
info!("Configuration saved via web setup. Restarting recommended.");
|
||||
info!("Configuration saved via web setup. Auth info created.");
|
||||
|
||||
axum::Json(serde_json::json!({ "status": "saved" })).into_response()
|
||||
axum::Json(serde_json::json!({ "status": "saved", "token": token })).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
@@ -444,41 +477,80 @@ async fn ready_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LoginPayload {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Json(payload): axum::Json<LoginPayload>,
|
||||
) -> impl IntoResponse {
|
||||
let user = match state.db.get_user_by_username(&payload.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
|
||||
};
|
||||
|
||||
let parsed_hash = match PasswordHash::new(&user.password_hash) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid hash format").into_response(),
|
||||
};
|
||||
|
||||
if Argon2::default()
|
||||
.verify_password(payload.password.as_bytes(), &parsed_hash)
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||
}
|
||||
|
||||
// Create session
|
||||
let token: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let expires_at = Utc::now() + chrono::Duration::days(30);
|
||||
|
||||
if let Err(e) = state.db.create_session(user.id, &token, expires_at).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create session: {}", e)).into_response();
|
||||
}
|
||||
|
||||
axum::Json(serde_json::json!({ "token": token })).into_response()
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = req.uri().path();
|
||||
// Allow setup routes without auth
|
||||
if path.starts_with("/api/setup") {
|
||||
// Allow setup, login, and static assets without auth (simplified check)
|
||||
if path.starts_with("/api/setup") || path.starts_with("/api/auth/login") || path.starts_with("/login") || path == "/" || path == "/index.html" || path.starts_with("/assets") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
if let Ok(password) = std::env::var("ALCHEMIST_PASSWORD") {
|
||||
if !password.is_empty() {
|
||||
// For static assets, we might want to bypass auth or require cookie auth
|
||||
// For now, implementing simple bearer token check from original code
|
||||
// NOTE: Browser won't send Bearer token for initial page load naturally.
|
||||
// We might need to move to Cookie auth or allow basic auth.
|
||||
// But for now, let's keep it as is or allow "/" to be public if needed?
|
||||
// The user didn't specify auth changes. I'll leave the middleware applied globally.
|
||||
// Check for Bearer token
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok());
|
||||
|
||||
let authorized = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s == format!("Bearer {}", password))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !authorized {
|
||||
// If requesting HTML, maybe return 401 asking for auth?
|
||||
// Or just 401.
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
if let Some(auth_str) = auth_header {
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
let token = &auth_str[7..];
|
||||
if let Ok(Some(_session)) = state.db.get_session(token).await {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
|
||||
// Attempt to verify if any users exist. If not, maybe allow access or force setup?
|
||||
// Actually, if we are in setup mode, setup routes are allowed.
|
||||
// If setup is done but no users? That shouldn't happen if setup creates a user.
|
||||
// If we are strictly enforcing auth:
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||
}
|
||||
|
||||
async fn sse_handler(
|
||||
|
||||
@@ -8,16 +8,28 @@ import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
Save
|
||||
Save,
|
||||
User,
|
||||
Lock,
|
||||
Video,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ConfigState {
|
||||
// Auth
|
||||
username: string;
|
||||
password: string;
|
||||
// Transcode
|
||||
size_reduction_threshold: number;
|
||||
min_file_size_mb: number;
|
||||
concurrent_jobs: number;
|
||||
directories: string[];
|
||||
output_codec: "av1" | "hevc";
|
||||
quality_profile: "quality" | "balanced" | "speed";
|
||||
// Hardware
|
||||
allow_cpu_encoding: boolean;
|
||||
// Scanner
|
||||
directories: string[];
|
||||
}
|
||||
|
||||
export default function SetupWizard() {
|
||||
@@ -26,17 +38,32 @@ export default function SetupWizard() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Tooltip state
|
||||
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
username: '',
|
||||
password: '',
|
||||
size_reduction_threshold: 0.3,
|
||||
min_file_size_mb: 100,
|
||||
concurrent_jobs: 2,
|
||||
output_codec: 'av1',
|
||||
quality_profile: 'balanced',
|
||||
directories: ['/media/movies'],
|
||||
allow_cpu_encoding: false
|
||||
});
|
||||
|
||||
const [dirInput, setDirInput] = useState('');
|
||||
|
||||
const handleNext = () => setStep(s => Math.min(s + 1, 4));
|
||||
const handleNext = () => {
|
||||
if (step === 1 && (!config.username || !config.password)) {
|
||||
setError("Please fill in both username and password.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setStep(s => Math.min(s + 1, 5));
|
||||
};
|
||||
|
||||
const handleBack = () => setStep(s => Math.max(s - 1, 1));
|
||||
|
||||
const addDirectory = () => {
|
||||
@@ -69,32 +96,50 @@ export default function SetupWizard() {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.token) {
|
||||
// Save token
|
||||
localStorage.setItem('alchemist_token', data.token);
|
||||
// Also set basic auth for legacy (optional) or just rely on new check
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Auto redirect after short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to save configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 text-center max-w-lg mx-auto">
|
||||
<div className="w-16 h-16 bg-status-success/20 rounded-full flex items-center justify-center mb-6 text-status-success">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-helios-ink mb-4">Configuration Saved!</h2>
|
||||
<p className="text-helios-slate mb-8">
|
||||
Alchemist has been successfully configured.
|
||||
<br /><br />
|
||||
<span className="font-bold text-helios-ink">Please restart the server (or Docker container) to apply changes.</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Total steps = 5
|
||||
// 1: Account
|
||||
// 2: Transcoding (Codec, Profile)
|
||||
// 3: Thresholds
|
||||
// 4: Hardware
|
||||
// 5: Review & Save
|
||||
|
||||
// Combined Steps for brevity:
|
||||
// 1: Account
|
||||
// 2: Transcode Rules (Codec, Profile, Thresholds)
|
||||
// 3: Hardware & Directories
|
||||
// 4: Review
|
||||
|
||||
// Let's stick to 4 logical steps but grouped differently?
|
||||
// User requested "username and password be the first step".
|
||||
|
||||
// Step 1: Account
|
||||
// Step 2: Codec & Quality (New)
|
||||
// Step 3: Performance & Directories (Merged old 2/3)
|
||||
// Step 4: Review
|
||||
|
||||
return (
|
||||
<div className="bg-helios-surface border border-helios-line/60 rounded-2xl overflow-hidden shadow-2xl max-w-2xl w-full mx-auto">
|
||||
@@ -103,7 +148,7 @@ export default function SetupWizard() {
|
||||
<motion.div
|
||||
className="bg-helios-solar h-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(step / 4) * 100}%` }}
|
||||
animate={{ width: `${(step / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,10 +171,143 @@ export default function SetupWizard() {
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<User size={20} className="text-helios-solar" />
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">
|
||||
Secure your Alchemist dashboard with a username and password.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 text-helios-slate opacity-50" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={config.username}
|
||||
onChange={(e) => setConfig({ ...config, username: e.target.value })}
|
||||
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-10 pr-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 text-helios-slate opacity-50" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
value={config.password}
|
||||
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||||
className="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-10 pr-3 py-2 text-helios-ink focus:border-helios-solar outline-none"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Video size={20} className="text-helios-solar" />
|
||||
Transcoding Preferences
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Codec Selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Output Codec</label>
|
||||
<div className="relative group">
|
||||
<Info size={14} className="text-helios-slate cursor-help hover:text-helios-solar transition-colors" />
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 w-48 p-2 bg-helios-ink text-helios-main text-[10px] rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
||||
Determines the video format for processed files. AV1 provides better compression but requires newer hardware.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, output_codec: "av1" })}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
|
||||
config.output_codec === "av1"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">AV1</span>
|
||||
<span className="text-xs text-center opacity-70">Best compression. Requires Arc/RTX 4000+.</span>
|
||||
{/* Hover Tooltip */}
|
||||
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
|
||||
Excellent efficiency. Ideal for Intel Arc GPUs.
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, output_codec: "hevc" })}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-4 rounded-xl border transition-all relative group",
|
||||
config.output_codec === "hevc"
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink shadow-sm"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate hover:bg-helios-surface-soft/80"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">HEVC</span>
|
||||
<span className="text-xs text-center opacity-70">Broad compatibility. Fast encoding.</span>
|
||||
{/* Hover Tooltip */}
|
||||
<div className="absolute inset-0 bg-helios-ink/90 text-helios-main p-4 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs text-center">
|
||||
Standard H.265. Compatible with most modern devices.
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Profile */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold uppercase tracking-wider text-helios-slate">Quality Profile</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["speed", "balanced", "quality"] as const).map((profile) => (
|
||||
<button
|
||||
key={profile}
|
||||
onClick={() => setConfig({ ...config, quality_profile: profile })}
|
||||
className={clsx(
|
||||
"p-3 rounded-lg border capitalize transition-all",
|
||||
config.quality_profile === profile
|
||||
? "bg-helios-solar/10 border-helios-solar text-helios-ink font-bold"
|
||||
: "bg-helios-surface-soft border-helios-line/30 text-helios-slate"
|
||||
)}
|
||||
>
|
||||
{profile}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Settings size={20} className="text-helios-solar" />
|
||||
Transcoding Rules
|
||||
Processing Rules
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -146,7 +324,7 @@ export default function SetupWizard() {
|
||||
onChange={(e) => setConfig({ ...config, size_reduction_threshold: parseFloat(e.target.value) })}
|
||||
className="w-full accent-helios-solar h-2 bg-helios-surface-soft rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-helios-slate mt-1">Files must shrink by at least this amount to be kept.</p>
|
||||
<p className="text-xs text-helios-slate mt-1">Files will be reverted if they don't shrink by this much.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -164,9 +342,9 @@ export default function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
key="step4"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
@@ -174,7 +352,7 @@ export default function SetupWizard() {
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<Cpu size={20} className="text-helios-solar" />
|
||||
Hardware & Performance
|
||||
Environment
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -192,80 +370,40 @@ export default function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40">
|
||||
<div className="flex-1">
|
||||
<span className="block text-sm font-medium text-helios-ink">Allow CPU Encoding</span>
|
||||
<span className="text-xs text-helios-slate">Fallback to software encoding if GPU is unavailable. Warning: Slow.</span>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<div className="space-y-4 pt-4 border-t border-helios-line/20">
|
||||
<label className="block text-sm font-medium text-helios-slate mb-2">Media Directories</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.allow_cpu_encoding}
|
||||
onChange={(e) => setConfig({ ...config, allow_cpu_encoding: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
type="text"
|
||||
placeholder="/path/to/media"
|
||||
value={dirInput}
|
||||
onChange={(e) => setDirInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
|
||||
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-helios-surface rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-helios-solar"></div>
|
||||
</label>
|
||||
<button
|
||||
onClick={addDirectory}
|
||||
className="px-4 py-2 bg-helios-surface-soft hover:bg-helios-surface-soft/80 text-helios-ink rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{config.directories.map((dir, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group">
|
||||
<span className="font-mono text-xs text-helios-ink truncate">{dir}</span>
|
||||
<button onClick={() => removeDirectory(dir)} className="text-status-error hover:text-status-error/80">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-helios-ink flex items-center gap-2">
|
||||
<FolderOpen size={20} className="text-helios-solar" />
|
||||
Media Directories
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="/path/to/media"
|
||||
value={dirInput}
|
||||
onChange={(e) => setDirInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDirectory()}
|
||||
className="flex-1 bg-helios-surface-soft border border-helios-line/40 rounded-lg px-3 py-2 text-helios-ink focus:border-helios-solar outline-none font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={addDirectory}
|
||||
className="px-4 py-2 bg-helios-surface-soft hover:bg-helios-surface-soft/80 text-helios-ink rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{config.directories.length === 0 && (
|
||||
<p className="text-center text-helios-slate italic py-4">No directories added yet</p>
|
||||
)}
|
||||
{config.directories.map((dir, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-helios-surface-soft/50 border border-helios-line/20 group">
|
||||
<span className="font-mono text-sm text-helios-ink truncate">{dir}</span>
|
||||
<button
|
||||
onClick={() => removeDirectory(dir)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-status-error hover:bg-status-error/10 rounded transition-all"
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step4"
|
||||
key="step5"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
@@ -279,28 +417,28 @@ export default function SetupWizard() {
|
||||
<div className="space-y-4 text-sm text-helios-slate">
|
||||
<div className="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>Reduction Threshold</span>
|
||||
<span className="text-helios-ink font-mono">{Math.round(config.size_reduction_threshold * 100)}%</span>
|
||||
<span>User</span>
|
||||
<span className="text-helios-ink font-bold">{config.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Min File Size</span>
|
||||
<span className="text-helios-ink font-mono">{config.min_file_size_mb} MB</span>
|
||||
<span>Codec</span>
|
||||
<span className="text-helios-ink font-mono uppercase">{config.output_codec}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Profile</span>
|
||||
<span className="text-helios-ink capitalize">{config.quality_profile}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Concurrency</span>
|
||||
<span className="text-helios-ink font-mono">{config.concurrent_jobs} jobs</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>CPU Encoding</span>
|
||||
<span className={config.allow_cpu_encoding ? "text-status-warning" : "text-helios-ink"}>
|
||||
{config.allow_cpu_encoding ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-helios-line/20">
|
||||
<span className="block mb-1">Directories:</span>
|
||||
<ul className="list-disc list-inside font-mono text-xs text-helios-ink">
|
||||
{config.directories.map(d => <li key={d}>{d}</li>)}
|
||||
</ul>
|
||||
<span className="block mb-1">Directories ({config.directories.length}):</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{config.directories.map(d => (
|
||||
<span key={d} className="px-1.5 py-0.5 bg-helios-surface border border-helios-line/30 rounded text-xs font-mono">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,7 +465,7 @@ export default function SetupWizard() {
|
||||
Back
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
{step < 5 ? (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
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"
|
||||
@@ -338,11 +476,11 @@ export default function SetupWizard() {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
disabled={loading || success}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-status-success text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Configuration"}
|
||||
{!loading && <Save size={18} />}
|
||||
{loading ? "Activating..." : success ? "Redirecting..." : "Launch Alchemist"}
|
||||
{!loading && !success && <Save size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,9 +52,7 @@ const navItems = [
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40"
|
||||
>
|
||||
<div class="mt-auto">
|
||||
<SystemStatus client:load />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Activity, X, Zap, CheckCircle2, AlertTriangle, Database } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
active: number;
|
||||
concurrent_limit: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function SystemStatus() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
@@ -17,7 +28,10 @@ export default function SystemStatus() {
|
||||
const data = await res.json();
|
||||
setStats({
|
||||
active: data.active || 0,
|
||||
concurrent_limit: data.concurrent_limit || 1
|
||||
concurrent_limit: data.concurrent_limit || 1,
|
||||
completed: data.completed || 0,
|
||||
failed: data.failed || 0,
|
||||
total: data.total || 0,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -26,7 +40,6 @@ export default function SystemStatus() {
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
// Poll every 5 seconds
|
||||
const interval = setInterval(fetchStats, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
@@ -34,7 +47,7 @@ export default function SystemStatus() {
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">System Status</span>
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Loading Status...</span>
|
||||
<span className="w-2 h-2 rounded-full bg-helios-slate/50 animate-pulse"></span>
|
||||
</div>
|
||||
);
|
||||
@@ -45,55 +58,170 @@ export default function SystemStatus() {
|
||||
const percentage = Math.min((stats.active / stats.concurrent_limit) * 100, 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
|
||||
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Engine Status</span>
|
||||
</div>
|
||||
<span 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'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-end justify-between text-helios-ink">
|
||||
<span className="text-xs font-medium opacity-80">Active Jobs</span>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
|
||||
{stats.active}
|
||||
</span>
|
||||
<span className="text-xs text-helios-slate">
|
||||
/ {stats.concurrent_limit}
|
||||
<>
|
||||
{/* Compact Sidebar View */}
|
||||
<motion.div
|
||||
layoutId="status-container"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="flex flex-col gap-3 cursor-pointer group p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40 shadow-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
|
||||
<span className={`relative inline-flex rounded-full h-2 w-2 ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider group-hover:text-helios-inc transition-colors">Engine Status</span>
|
||||
</div>
|
||||
<motion.div layoutId="status-badge" 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'}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
{/* Tick marks for job slots */}
|
||||
<div className="absolute inset-0 flex justify-between px-[1px]">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-end justify-between text-helios-ink">
|
||||
<span className="text-xs font-medium opacity-80">Active Jobs</span>
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<motion.span layoutId="active-count" className={`text-lg font-bold ${isFull ? 'text-status-warning' : 'text-helios-solar'}`}>
|
||||
{stats.active}
|
||||
</motion.span>
|
||||
<span className="text-xs text-helios-slate">
|
||||
/ {stats.concurrent_limit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full bg-helios-line/20 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
layoutId="progress-bar"
|
||||
className={`h-full transition-all duration-700 ease-out rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
{Array.from({ length: stats.concurrent_limit }).map((_, i) => (
|
||||
<div key={i} className="w-[1px] h-full bg-helios-main/20" style={{ left: `${((i + 1) / stats.concurrent_limit) * 100}%` }} />
|
||||
<div key={i} className="absolute top-0 bottom-0 w-[1px] bg-helios-main/20" style={{ left: `${((i + 1) / stats.concurrent_limit) * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isActive && (
|
||||
<div className="text-[10px] text-helios-slate flex items-center gap-1.5 animate-pulse">
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
<span>Processing media...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Expanded Modal View */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-md flex items-center justify-center p-4"
|
||||
>
|
||||
{/* Modal Card */}
|
||||
<motion.div
|
||||
layoutId="status-container"
|
||||
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-3xl shadow-2xl overflow-hidden relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header Background Effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-helios-solar/10 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="p-8 relative">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-helios-surface-soft rounded-xl border border-helios-line/20 shadow-sm">
|
||||
<Activity className="text-helios-solar" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-helios-ink tracking-tight">System Status</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-status-success' : 'bg-helios-slate'}`}></span>
|
||||
<span className="text-xs font-medium text-helios-slate uppercase tracking-wide">
|
||||
{isActive ? 'Engine Running' : 'Engine Idle'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="p-2 hover:bg-helios-surface-soft rounded-full text-helios-slate hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-helios-surface-soft/50 rounded-2xl p-5 border border-helios-line/10 flex flex-col items-center text-center gap-2">
|
||||
<Zap size={20} className="text-helios-solar opacity-80" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Concurrency</span>
|
||||
<div className="flex items-baseline justify-center gap-1 mt-1">
|
||||
<motion.span layoutId="active-count" className="text-3xl font-bold text-helios-ink">
|
||||
{stats.active}
|
||||
</motion.span>
|
||||
<span className="text-sm font-medium text-helios-slate opacity-60">
|
||||
/ {stats.concurrent_limit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Big Progress Bar */}
|
||||
<div className="w-full h-2 bg-helios-line/10 rounded-full mt-2 overflow-hidden relative">
|
||||
<motion.div
|
||||
layoutId="progress-bar"
|
||||
className={`h-full rounded-full ${isFull ? 'bg-status-warning' : 'bg-helios-solar'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Database size={20} className="text-blue-400 opacity-80" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-helios-slate uppercase tracking-wider">Total Jobs</span>
|
||||
<span className="text-3xl font-bold text-helios-ink mt-1">
|
||||
{stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-helios-slate mt-1 px-2 py-0.5 bg-helios-line/10 rounded-md">
|
||||
Lifetime
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Metrics Row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-xl bg-status-success/5 border border-status-success/10 flex flex-col items-center justify-center text-center">
|
||||
<CheckCircle2 size={16} className="text-status-success mb-1" />
|
||||
<span className="text-lg font-bold text-helios-ink">{stats.completed}</span>
|
||||
<span className="text-[10px] font-bold text-status-success uppercase tracking-wider">Completed</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-xl bg-status-error/5 border border-status-error/10 flex flex-col items-center justify-center text-center">
|
||||
<AlertTriangle size={16} className="text-status-error mb-1" />
|
||||
<span className="text-lg font-bold text-helios-ink">{stats.failed}</span>
|
||||
<span className="text-[10px] font-bold text-status-error uppercase tracking-wider">Failed</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-xl bg-helios-surface-soft border border-helios-line/10 flex flex-col items-center justify-center text-center opacity-60">
|
||||
<Activity size={16} className="text-helios-slate mb-1" />
|
||||
<span className="text-lg font-bold text-helios-ink">--</span>
|
||||
<span className="text-[10px] font-bold text-helios-slate uppercase tracking-wider">Est. Time</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-helios-line/10 text-center">
|
||||
<p className="text-xs text-helios-slate/60">
|
||||
System metrics update automatically every 5 seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,11 +32,38 @@ const { title } = Astro.props;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
const isAuthPage = window.location.pathname.startsWith('/login') || window.location.pathname.startsWith('/setup');
|
||||
const token = localStorage.getItem('alchemist_token');
|
||||
|
||||
if (!isAuthPage && !token) {
|
||||
// Check if setup is needed
|
||||
fetch('/api/setup/status')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.setup_required) {
|
||||
window.location.href = '/setup';
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If API fails (server down?), maybe just stay? Or go to login?
|
||||
// Safest to go to login
|
||||
window.location.href = '/login';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run on initial load
|
||||
initTheme();
|
||||
checkAuth();
|
||||
|
||||
// Run on view transition navigation
|
||||
document.addEventListener('astro:after-swap', initTheme);
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
initTheme();
|
||||
checkAuth();
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Check setup status on load
|
||||
|
||||
94
web/src/pages/login.astro
Normal file
94
web/src/pages/login.astro
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { User, Lock, ArrowRight, AlertTriangle } from "lucide-react";
|
||||
---
|
||||
|
||||
<Layout title="Alchemist | Login">
|
||||
<div class="min-h-screen bg-helios-main flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md bg-helios-surface border border-helios-line/60 rounded-2xl shadow-2xl overflow-hidden p-8">
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<div class="w-12 h-12 rounded-xl bg-helios-solar text-helios-main flex items-center justify-center font-bold text-2xl mb-4">
|
||||
A
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-helios-ink">Welcome Back</h1>
|
||||
<p class="text-helios-slate">Sign in to your dashboard</p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-helios-slate mb-2">Username</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
class="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-4 pr-3 py-3 text-helios-ink focus:border-helios-solar outline-none transition-colors"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-helios-slate mb-2">Password</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full bg-helios-surface-soft border border-helios-line/40 rounded-lg pl-4 pr-3 py-3 text-helios-ink focus:border-helios-solar outline-none transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-msg" class="hidden p-3 rounded-lg bg-status-error/10 border border-status-error/30 text-status-error text-sm flex items-center gap-2">
|
||||
<span>Invalid credentials</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-helios-solar text-helios-main font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Sign In
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form') as HTMLFormElement;
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// Reset error
|
||||
errorMsg?.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const { token } = await res.json();
|
||||
localStorage.setItem('alchemist_token', token);
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMsg?.classList.remove('hidden');
|
||||
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Invalid username or password";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorMsg?.classList.remove('hidden');
|
||||
if (errorMsg) errorMsg.querySelector('span')!.textContent = "Connection failed";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user