/* 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::{ account::session::AdminSession, assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}, }; use crate::{ database::Database, routes::ui::{ admin::log::rocket_uri_macro_r_admin_log, error::MyResult, layout::{DynLayoutPage, FlashDisplay, LayoutPage}, }, uri, }; use anyhow::{anyhow, Context}; use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; use markup::DynRender; use rand::Rng; use rocket::{form::Form, get, post, FromForm, State}; use std::time::Instant; use tokio::{sync::Semaphore, task::spawn_blocking}; use user::rocket_uri_macro_r_admin_users; #[get("/admin/dashboard")] pub async fn r_admin_dashboard( _session: AdminSession, database: &State, ) -> MyResult> { admin_dashboard(database, None).await } pub async fn admin_dashboard<'a>( database: &Database, flash: Option>, ) -> MyResult> { let invites = database.list_invites()?; let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); let last_import_err = IMPORT_ERRORS.read().await.to_owned(); let database = database.to_owned(); Ok(LayoutPage { title: "Admin Dashboard".to_string(), content: markup::new! { h1 { "Admin Panel" } @FlashDisplay { flash: flash.clone() } @if !last_import_err.is_empty() { section.message.error { details { summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } } ol { @for e in &last_import_err { li.error { pre.error { @e } } }} } } } ul { li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }} li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }} } h2 { "Library" } @if is_importing() { section.message { p.warn { "An import is currently running." } } } @if is_transcoding() { section.message { p.warn { "Currently transcoding posters." } } } form[method="POST", action=uri!(r_admin_import(true, false))] { input[type="submit", disabled=is_importing(), value="Start incremental import"]; } form[method="POST", action=uri!(r_admin_import(false, false))] { input[type="submit", disabled=is_importing(), value="Start full import"]; } form[method="POST", action=uri!(r_admin_import(false, true))] { input[type="submit", disabled=is_importing(), value="Clear all nodes"]; } form[method="POST", action=uri!(r_admin_transcode_posters())] { input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"]; } form[method="POST", action=uri!(r_admin_update_search())] { input[type="submit", value="Update full-text search index"]; } form[method="POST", action=uri!(r_admin_delete_cache())] { input.danger[type="submit", value="Delete Cache"]; } h2 { "Users" } p { a[href=uri!(r_admin_users())] "Manage Users" } h2 { "Invitations" } form[method="POST", action=uri!(r_admin_invite())] { input[type="submit", value="Generate new invite code"]; } ul { @for t in &invites { li { form[method="POST", action=uri!(r_admin_remove_invite())] { span { @t } input[type="text", name="invite", value=&t, hidden]; input[type="submit", value="Invalidate"]; } } }} h2 { "Database" } @match db_stats(&database) { Ok(s) => { @s } Err(e) => { pre.error { @format!("{e:?}") } } } }, ..Default::default() }) } #[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 } #[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 } #[post("/admin/import?&")] pub async fn r_admin_import( session: AdminSession, database: &State, _federation: &State, incremental: bool, nuke: bool, ) -> MyResult> { drop(session); let t = Instant::now(); let flash = if nuke { database.clear_nodes()?; Ok(format!("All nodes cleared.")) } else { let r = import_wrap((*database).clone(), incremental).await; r.map_err(|e| e.into()) .map(|_| format!("Import successful; took {:?}", t.elapsed())) }; admin_dashboard(database, Some(flash)).await } #[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(|_| format!("Search index updated")), ), ) .await } #[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 } 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 } 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 } // } }) }