diff options
Diffstat (limited to 'server/src/ui/admin/mod.rs')
-rw-r--r-- | server/src/ui/admin/mod.rs | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs new file mode 100644 index 0000000..de06610 --- /dev/null +++ b/server/src/ui/admin/mod.rs @@ -0,0 +1,288 @@ +/* + 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 <metamuffin.org> +*/ +pub mod log; +pub mod user; + +use super::assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}; +use crate::{ + database::Database, + logic::session::AdminSession, + 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<Database>, +) -> MyResult<DynLayoutPage<'static>> { + admin_dashboard(database, None).await +} + +pub async fn admin_dashboard<'a>( + database: &Database, + flash: Option<MyResult<String>>, +) -> MyResult<DynLayoutPage<'a>> { + 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))] { + input[type="submit", disabled=is_importing(), value="Start incremental import"]; + } + form[method="POST", action=uri!(r_admin_import(false))] { + input[type="submit", disabled=is_importing(), value="Start full import"]; + } + 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="Regenerate 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<Database>, +) -> MyResult<DynLayoutPage<'static>> { + let i = format!("{}", rand::rng().random::<u128>()); + 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 = "<form>")] +pub async fn r_admin_remove_invite( + session: AdminSession, + database: &State<Database>, + form: Form<DeleteInvite>, +) -> MyResult<DynLayoutPage<'static>> { + 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?<incremental>")] +pub async fn r_admin_import( + session: AdminSession, + database: &State<Database>, + _federation: &State<Federation>, + incremental: bool, +) -> MyResult<DynLayoutPage<'static>> { + 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 +} + +#[post("/admin/update_search")] +pub async fn r_admin_update_search( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + 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 +} + +#[post("/admin/delete_cache")] +pub async fn r_admin_delete_cache( + session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + 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<Database>, +) -> MyResult<DynLayoutPage<'static>> { + 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<DynRender> { + // 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 } + // } + }) +} |