aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui/admin/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/ui/admin/mod.rs')
-rw-r--r--server/src/ui/admin/mod.rs288
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 }
+ // }
+ })
+}