diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index 18143d1..4ad0e9a 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -16,6 +16,7 @@ use serde::Deserialize; use utoipa::ToSchema; use sha2::{Digest, Sha256}; use sqlx::Row; +use tracing::{debug, error, info, instrument, warn}; use uuid::Uuid; use crate::{error::ApiError, AppState}; @@ -66,7 +67,7 @@ fn write_to_disk_cache(cache_path: &Path, data: &[u8]) -> Result<(), std::io::Er Ok(()) } -#[derive(Deserialize, ToSchema)] +#[derive(Deserialize, ToSchema, Debug)] pub struct PageQuery { #[schema(value_type = Option, example = "webp")] pub format: Option, @@ -76,7 +77,7 @@ pub struct PageQuery { pub width: Option, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] enum OutputFormat { Jpeg, Png, @@ -130,12 +131,16 @@ impl OutputFormat { ), security(("Bearer" = [])) )] +#[instrument(skip(state), fields(book_id = %book_id, page = n))] pub async fn get_page( State(state): State, AxumPath((book_id, n)): AxumPath<(Uuid, u32)>, Query(query): Query, ) -> Result { + info!("Processing image request"); + if n == 0 { + warn!("Invalid page number: 0"); return Err(ApiError::bad_request("page index starts at 1")); } @@ -143,6 +148,7 @@ pub async fn get_page( let quality = query.quality.unwrap_or(80).clamp(1, 100); let width = query.width.unwrap_or(0); if width > 2160 { + warn!("Invalid width: {}", width); return Err(ApiError::bad_request("width must be <= 2160")); } @@ -150,9 +156,11 @@ pub async fn get_page( if let Some(cached) = state.page_cache.lock().await.get(&memory_cache_key).cloned() { state.metrics.page_cache_hits.fetch_add(1, Ordering::Relaxed); + debug!("Memory cache hit for key: {}", memory_cache_key); return Ok(image_response(cached, format.content_type(), None)); } state.metrics.page_cache_misses.fetch_add(1, Ordering::Relaxed); + debug!("Memory cache miss for key: {}", memory_cache_key); let row = sqlx::query( r#" @@ -165,31 +173,52 @@ pub async fn get_page( ) .bind(book_id) .fetch_optional(&state.pool) - .await?; + .await + .map_err(|e| { + error!("Database error fetching book file for book_id {}: {}", book_id, e); + e + })?; - let row = row.ok_or_else(|| ApiError::not_found("book file not found"))?; + let row = match row { + Some(r) => r, + None => { + error!("Book file not found for book_id: {}", book_id); + return Err(ApiError::not_found("book file not found")); + } + }; + let abs_path: String = row.get("abs_path"); let abs_path = remap_libraries_path(&abs_path); let input_format: String = row.get("format"); + + info!("Processing book file: {} (format: {})", abs_path, input_format); let disk_cache_key = get_cache_key(&abs_path, n, format.extension(), quality, width); let cache_path = get_cache_path(&disk_cache_key, &format); if let Some(cached_bytes) = read_from_disk_cache(&cache_path) { + info!("Disk cache hit for: {}", cache_path.display()); let bytes = Arc::new(cached_bytes); state.page_cache.lock().await.put(memory_cache_key, bytes.clone()); return Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key))); } + debug!("Disk cache miss for: {}", cache_path.display()); let _permit = state .page_render_limit .clone() .acquire_owned() .await - .map_err(|_| ApiError::internal("render limiter unavailable"))?; + .map_err(|e| { + error!("Failed to acquire render permit: {}", e); + ApiError::internal("render limiter unavailable") + })?; + info!("Rendering page {} from {}", n, abs_path); let abs_path_clone = abs_path.clone(); let format_clone = format; + let start_time = std::time::Instant::now(); + let bytes = tokio::time::timeout( Duration::from_secs(12), tokio::task::spawn_blocking(move || { @@ -197,15 +226,37 @@ pub async fn get_page( }), ) .await - .map_err(|_| ApiError::internal("page rendering timeout"))? - .map_err(|e| ApiError::internal(format!("render task failed: {e}")))??; + .map_err(|_| { + error!("Page rendering timeout for {} page {}", abs_path, n); + ApiError::internal("page rendering timeout") + })? + .map_err(|e| { + error!("Render task panicked for {} page {}: {}", abs_path, n, e); + ApiError::internal(format!("render task failed: {e}")) + })?; + + let duration = start_time.elapsed(); + + match bytes { + Ok(data) => { + info!("Successfully rendered page {} in {:?}", n, duration); + + if let Err(e) = write_to_disk_cache(&cache_path, &data) { + warn!("Failed to write to disk cache: {}", e); + } else { + info!("Cached rendered image to: {}", cache_path.display()); + } - let _ = write_to_disk_cache(&cache_path, &bytes); + let bytes = Arc::new(data); + state.page_cache.lock().await.put(memory_cache_key, bytes.clone()); - let bytes = Arc::new(bytes); - state.page_cache.lock().await.put(memory_cache_key, bytes.clone()); - - Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key))) + Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key))) + } + Err(e) => { + error!("Failed to render page {} from {}: {:?}", n, abs_path, e); + Err(e) + } + } } fn image_response(bytes: Arc>, content_type: &str, etag_suffix: Option<&str>) -> Response { @@ -246,34 +297,63 @@ fn render_page( } fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result, ApiError> { - let file = std::fs::File::open(abs_path).map_err(|e| ApiError::internal(format!("cannot open cbz: {e}")))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| ApiError::internal(format!("invalid cbz: {e}")))?; + debug!("Opening CBZ archive: {}", abs_path); + let file = std::fs::File::open(abs_path).map_err(|e| { + error!("Cannot open CBZ file {}: {}", abs_path, e); + ApiError::internal(format!("cannot open cbz: {e}")) + })?; + + let mut archive = zip::ZipArchive::new(file).map_err(|e| { + error!("Invalid CBZ archive {}: {}", abs_path, e); + ApiError::internal(format!("invalid cbz: {e}")) + })?; let mut image_names: Vec = Vec::new(); for i in 0..archive.len() { - let entry = archive.by_index(i).map_err(|e| ApiError::internal(format!("cbz entry read failed: {e}")))?; + let entry = archive.by_index(i).map_err(|e| { + error!("Failed to read CBZ entry {} in {}: {}", i, abs_path, e); + ApiError::internal(format!("cbz entry read failed: {e}")) + })?; let name = entry.name().to_ascii_lowercase(); if is_image_name(&name) { image_names.push(entry.name().to_string()); } } image_names.sort(); + debug!("Found {} images in CBZ {}", image_names.len(), abs_path); let index = page_number as usize - 1; - let selected = image_names.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?; - let mut entry = archive.by_name(selected).map_err(|e| ApiError::internal(format!("cbz page read failed: {e}")))?; + let selected = image_names.get(index).ok_or_else(|| { + error!("Page {} out of range in {} (total: {})", page_number, abs_path, image_names.len()); + ApiError::not_found("page out of range") + })?; + + debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path); + let mut entry = archive.by_name(selected).map_err(|e| { + error!("Failed to read CBZ page {} from {}: {}", selected, abs_path, e); + ApiError::internal(format!("cbz page read failed: {e}")) + })?; let mut buf = Vec::new(); - entry.read_to_end(&mut buf).map_err(|e| ApiError::internal(format!("cbz page load failed: {e}")))?; + entry.read_to_end(&mut buf).map_err(|e| { + error!("Failed to load CBZ page {} from {}: {}", selected, abs_path, e); + ApiError::internal(format!("cbz page load failed: {e}")) + })?; Ok(buf) } fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result, ApiError> { + debug!("Listing CBR archive: {}", abs_path); let list_output = std::process::Command::new("unrar") .arg("lb") .arg(abs_path) .output() - .map_err(|e| ApiError::internal(format!("unrar list failed: {e}")))?; + .map_err(|e| { + error!("unrar list command failed for {}: {}", abs_path, e); + ApiError::internal(format!("unrar list failed: {e}")) + })?; if !list_output.status.success() { + let stderr = String::from_utf8_lossy(&list_output.stderr); + error!("unrar could not list archive {}: {}", abs_path, stderr); return Err(ApiError::internal("unrar could not list archive")); } @@ -283,25 +363,41 @@ fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result, ApiErro .map(|s| s.to_string()) .collect(); entries.sort(); - let index = page_number as usize - 1; - let selected = entries.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?; + debug!("Found {} images in CBR {}", entries.len(), abs_path); + let index = page_number as usize - 1; + let selected = entries.get(index).ok_or_else(|| { + error!("Page {} out of range in {} (total: {})", page_number, abs_path, entries.len()); + ApiError::not_found("page out of range") + })?; + + debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path); let page_output = std::process::Command::new("unrar") .arg("p") .arg("-inul") .arg(abs_path) .arg(selected) .output() - .map_err(|e| ApiError::internal(format!("unrar extract failed: {e}")))?; + .map_err(|e| { + error!("unrar extract command failed for {} page {}: {}", abs_path, selected, e); + ApiError::internal(format!("unrar extract failed: {e}")) + })?; if !page_output.status.success() { + let stderr = String::from_utf8_lossy(&page_output.stderr); + error!("unrar could not extract page {} from {}: {}", selected, abs_path, stderr); return Err(ApiError::internal("unrar could not extract page")); } + debug!("Successfully extracted {} bytes from CBR page {}", page_output.stdout.len(), page_number); Ok(page_output.stdout) } fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result, ApiError> { let tmp_dir = std::env::temp_dir().join(format!("stripstream-pdf-{}", Uuid::new_v4())); - std::fs::create_dir_all(&tmp_dir).map_err(|e| ApiError::internal(format!("cannot create temp dir: {e}")))?; + debug!("Creating temp dir for PDF rendering: {}", tmp_dir.display()); + std::fs::create_dir_all(&tmp_dir).map_err(|e| { + error!("Cannot create temp dir {}: {}", tmp_dir.display(), e); + ApiError::internal(format!("cannot create temp dir: {e}")) + })?; let output_prefix = tmp_dir.join("page"); let mut cmd = std::process::Command::new("pdftoppm"); @@ -314,35 +410,58 @@ fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result Result, ApiError> { + debug!("Transcoding image: {} bytes, format: {:?}, quality: {}, width: {}", input.len(), out_format, quality, width); let source_format = image::guess_format(input).ok(); + debug!("Source format detected: {:?}", source_format); let needs_transcode = source_format.map(|f| !format_matches(&f, out_format)).unwrap_or(true); if width == 0 && !needs_transcode { + debug!("No transcoding needed, returning original"); return Ok(input.to_vec()); } - let mut image = image::load_from_memory(input).map_err(|e| ApiError::internal(format!("invalid source image: {e}")))?; + debug!("Loading image from memory..."); + let mut image = image::load_from_memory(input).map_err(|e| { + error!("Failed to load image from memory: {} (input size: {} bytes)", e, input.len()); + ApiError::internal(format!("invalid source image: {e}")) + })?; + if width > 0 { + debug!("Resizing image to width: {}", width); image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3); } + debug!("Converting to RGBA..."); let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); + debug!("Image dimensions: {}x{}", w, h); + let mut out = Vec::new(); match out_format { OutputFormat::Jpeg => { diff --git a/apps/backoffice/app/components/ui/Icon.tsx b/apps/backoffice/app/components/ui/Icon.tsx index 6e3912f..e49b281 100644 --- a/apps/backoffice/app/components/ui/Icon.tsx +++ b/apps/backoffice/app/components/ui/Icon.tsx @@ -1,50 +1,90 @@ -type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series" | "settings"; +type IconName = + | "dashboard" + | "books" + | "libraries" + | "jobs" + | "tokens" + | "series" + | "settings" + | "image" + | "cache" + | "performance" + | "folder" + | "folderOpen" + | "search" + | "plus" + | "edit" + | "trash" + | "check" + | "x" + | "chevronLeft" + | "chevronRight" + | "chevronUp" + | "chevronDown" + | "arrowLeft" + | "arrowRight" + | "refresh" + | "sun" + | "moon" + | "externalLink" + | "key" + | "play" + | "stop" + | "spinner" + | "warning"; -interface PageIconProps { +type IconSize = "sm" | "md" | "lg" | "xl"; + +interface IconProps { name: IconName; + size?: IconSize; className?: string; } -const icons: Record = { - dashboard: ( - - - - ), - books: ( - - - - ), - libraries: ( - - - - ), - jobs: ( - - - - ), - tokens: ( - - - - ), - series: ( - - - - ), - settings: ( - - - - - ), +const sizeClasses: Record = { + sm: "w-4 h-4", + md: "w-5 h-5", + lg: "w-6 h-6", + xl: "w-8 h-8", }; -const colors: Record = { +const icons: Record = { + dashboard: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", + books: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253", + libraries: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", + jobs: "M13 10V3L4 14h7v7l9-11h-7z", + tokens: "M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z", + series: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10", + settings: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", + image: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", + cache: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10", + performance: "M13 10V3L4 14h7v7l9-11h-7z", + folder: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", + folderOpen: "M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z", + search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", + plus: "M12 4v16m8-8H4", + edit: "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z", + trash: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16", + check: "M5 13l4 4L19 7", + x: "M6 18L18 6M6 6l12 12", + chevronLeft: "M15 19l-7-7 7-7", + chevronRight: "M9 5l7 7-7 7", + chevronUp: "M5 15l7-7 7 7", + chevronDown: "M19 9l-7 7-7-7", + arrowLeft: "M10 19l-7-7m0 0l7-7m-7 7h18", + arrowRight: "M14 5l7 7m0 0l-7 7m7-7H3", + refresh: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15", + sun: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z", + moon: "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z", + externalLink: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14", + key: "M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z", + play: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z", + stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z", + spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15", + warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z", +}; + +const colorClasses: Partial> = { dashboard: "text-primary", books: "text-success", libraries: "text-primary", @@ -52,56 +92,32 @@ const colors: Record = { tokens: "text-error", series: "text-primary", settings: "text-muted-foreground", + image: "text-primary", + cache: "text-warning", + performance: "text-success", }; -export function PageIcon({ name, className = "" }: PageIconProps) { +export function Icon({ name, size = "md", className = "" }: IconProps) { + const sizeClass = sizeClasses[size]; + const colorClass = colorClasses[name]; + return ( - - {icons[name]} - + + + ); } -// Nav icons (smaller) -export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) { - const navIcons: Record = { - dashboard: ( - - - - ), - books: ( - - - - ), - libraries: ( - - - - ), - jobs: ( - - - - ), - tokens: ( - - - - ), - series: ( - - - - ), - settings: ( - - - - - ), - }; - - return {navIcons[name]}; +// Backwards compatibility aliases +export function PageIcon({ name, className = "" }: { name: IconName; className?: string }) { + return ; +} + +export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) { + return ; } diff --git a/apps/backoffice/app/components/ui/index.ts b/apps/backoffice/app/components/ui/index.ts index 40fcec3..3c0c0af 100644 --- a/apps/backoffice/app/components/ui/index.ts +++ b/apps/backoffice/app/components/ui/index.ts @@ -17,5 +17,5 @@ export { FormField, FormLabel, FormInput, FormSelect, FormRow, FormSection, FormError, FormDescription } from "./Form"; -export { PageIcon, NavIcon } from "./Icon"; +export { PageIcon, NavIcon, Icon } from "./Icon"; export { CursorPagination, OffsetPagination } from "./Pagination"; diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx index 7e5dde2..6cbb6dc 100644 --- a/apps/backoffice/app/layout.tsx +++ b/apps/backoffice/app/layout.tsx @@ -6,7 +6,7 @@ import "./globals.css"; import { ThemeProvider } from "./theme-provider"; import { ThemeToggle } from "./theme-toggle"; import { JobsIndicator } from "./components/JobsIndicator"; -import { NavIcon } from "./components/ui"; +import { NavIcon, Icon } from "./components/ui"; export const metadata: Metadata = { title: "StripStream Backoffice", @@ -25,7 +25,6 @@ const navItems: NavItem[] = [ { href: "/libraries", label: "Libraries", icon: "libraries" }, { href: "/jobs", label: "Jobs", icon: "jobs" }, { href: "/tokens", label: "Tokens", icon: "tokens" }, - { href: "/settings", label: "Settings", icon: "settings" }, ]; export default function RootLayout({ children }: { children: ReactNode }) { @@ -72,6 +71,13 @@ export default function RootLayout({ children }: { children: ReactNode }) { {/* Actions */}
+ + +
diff --git a/apps/backoffice/app/settings/SettingsPage.tsx b/apps/backoffice/app/settings/SettingsPage.tsx index b0edaf4..1e83799 100644 --- a/apps/backoffice/app/settings/SettingsPage.tsx +++ b/apps/backoffice/app/settings/SettingsPage.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui"; import { Settings, CacheStats, ClearCacheResponse } from "../../lib/api"; interface SettingsPageProps { @@ -63,10 +63,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set <>

- - - - + Settings

@@ -82,7 +79,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set {/* Image Processing Settings */} - Image Processing + + + Image Processing + Configure how images are processed and compressed @@ -158,7 +158,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set {/* Cache Settings */} - Cache + + + Cache + Manage the image cache and storage @@ -218,17 +221,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set > {isClearing ? ( <> - - - - + Clearing... ) : ( <> - - - + Clear Cache )} @@ -240,7 +238,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set {/* Limits Settings */} - Performance Limits + + + Performance Limits + Configure API performance and rate limiting