/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ pub mod log; pub mod user; use super::{ assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}, error::MyResult, }; use crate::{database::Database, locale::AcceptLanguage}; use anyhow::{anyhow, Context}; use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; use jellycommon::routes::u_admin_dashboard; use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; use jellylogic::session::AdminSession; use jellyui::{ admin::AdminDashboardPage, render_page, scaffold::{RenderInfo, SessionInfo}, }; use rand::Rng; use rocket::{ form::Form, get, post, response::{content::RawHtml, Redirect}, FromForm, State, }; use std::time::Instant; use tokio::{sync::Semaphore, task::spawn_blocking}; #[get("/admin/dashboard")] pub async fn r_admin_dashboard( session: AdminSession, database: &State, lang: AcceptLanguage, ) -> MyResult> { let AcceptLanguage(lang) = lang; let invites = database.list_invites()?; let flash = None; let last_import_err = IMPORT_ERRORS.read().await.to_owned(); let busy = if is_importing() { Some("An import is currently running.") } else if is_transcoding() { Some("Currently transcoding posters.") } else { None }; Ok(RawHtml(render_page( &AdminDashboardPage { busy, last_import_err: &last_import_err, invites: &invites, flash, lang: &lang, }, RenderInfo { importing: is_importing(), session: Some(SessionInfo { user: session.0.user, }), }, lang, ))) } #[post("/admin/generate_invite")] pub async fn r_admin_invite( _session: AdminSession, database: &State, ) -> MyResult { let i = format!("{}", rand::rng().random::()); database.create_invite(&i)?; // admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await Ok(Redirect::temporary(u_admin_dashboard())) } #[derive(FromForm)] pub struct DeleteInvite { invite: String, } #[post("/admin/remove_invite", data = "
")] pub async fn r_admin_remove_invite( session: AdminSession, database: &State, form: Form, ) -> MyResult { drop(session); if !database.delete_invite(&form.invite)? { Err(anyhow!("invite does not exist"))?; }; // admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/import?")] pub async fn r_admin_import( session: AdminSession, database: &State, _federation: &State, incremental: bool, ) -> MyResult { drop(session); let t = Instant::now(); if !incremental { database.clear_nodes()?; } let r = import_wrap((*database).clone(), incremental).await; // let flash = r // .map_err(|e| e.into()) // .map(|_| format!("Import successful; took {:?}", t.elapsed())); // admin_dashboard(database, Some(flash)).await Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/update_search")] pub async fn r_admin_update_search( _session: AdminSession, database: &State, ) -> MyResult { let db2 = (*database).clone(); let r = spawn_blocking(move || db2.search_create_index()) .await .unwrap(); // admin_dashboard( // database, // Some( // r.map_err(|e| e.into()) // .map(|_| "Search index updated".to_string()), // ), // ) // .await Ok(Redirect::temporary(u_admin_dashboard())) } #[post("/admin/delete_cache")] pub async fn r_admin_delete_cache( session: AdminSession, database: &State, ) -> MyResult { drop(session); let t = Instant::now(); let r = tokio::fs::remove_dir_all(&CONF.cache_path).await; tokio::fs::create_dir(&CONF.cache_path).await?; // admin_dashboard( // database, // Some( // r.map_err(|e| e.into()) // .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), // ), // ) // .await Ok(Redirect::temporary(u_admin_dashboard())) } static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); fn is_transcoding() -> bool { SEM_TRANSCODING.available_permits() == 0 } #[post("/admin/transcode_posters")] pub async fn r_admin_transcode_posters( session: AdminSession, database: &State, ) -> MyResult { drop(session); let _permit = SEM_TRANSCODING .try_acquire() .context("transcoding in progress")?; let t = Instant::now(); { let nodes = database.list_nodes_with_udata("")?; for (node, _) in nodes { if let Some(poster) = &node.poster { let asset = AssetInner::deser(&poster.0)?; if asset.is_federated() { continue; } let source = resolve_asset(asset).await.context("resolving asset")?; jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024) .await .context("transcoding asset")?; } } } drop(_permit); // admin_dashboard( // database, // Some(Ok(format!( // "All posters pre-transcoded; took {:?}", // t.elapsed() // ))), // ) // .await Ok(Redirect::temporary(u_admin_dashboard())) } // fn db_stats(_db: &Database) -> anyhow::Result { // // TODO // // let txn = db.inner.begin_read()?; // // let stats = [ // // ("node", txn.open_table(T_NODE)?.stats()?), // // ("user", txn.open_table(T_USER_NODE)?.stats()?), // // ("user-node", txn.open_table(T_USER_NODE)?.stats()?), // // ("invite", txn.open_table(T_INVITE)?.stats()?), // // ]; // // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats(); // // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?; // Ok(markup::new! { // // h3 { "Key-Value-Store Statistics" } // // table.border { // // tbody { // // tr { // // th { "table name" } // // th { "tree height" } // // th { "stored bytes" } // // th { "metadata bytes" } // // th { "fragmented bytes" } // // th { "branch pages" } // // th { "leaf pages" } // // } // // @for (name, stats) in &stats { tr { // // td { @name } // // td { @stats.tree_height() } // // td { @format_size(stats.stored_bytes(), DECIMAL) } // // td { @format_size(stats.metadata_bytes(), DECIMAL) } // // td { @format_size(stats.fragmented_bytes(), DECIMAL) } // // td { @stats.branch_pages() } // // td { @stats.leaf_pages() } // // }} // // } // // } // // h3 { "Search Engine Statistics" } // // ul { // // li { "Total documents: " @ft_total_docs } // // li { "Cache misses: " @cache_stats.cache_misses } // // li { "Cache hits: " @cache_stats.cache_hits } // // } // }) // }