mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-27 00:04:23 -04:00 
			
		
		
		
	feat: admin maintenance page (#1096)
* fix build typo * generate types * setup maintenance api for common cleanup actions * admin maintenance page * remove duplicate use-with-caution
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/backend-docker-nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/backend-docker-nightly.yml
									
									
									
									
										vendored
									
									
								
							| @@ -47,7 +47,7 @@ jobs: | ||||
|           docker build --push --no-cache \ | ||||
|             --tag hkotel/mealie:api-nightly \ | ||||
|             --platform linux/amd64,linux/arm64 . | ||||
|             --build-arg COMMIT=$(git rev-parse HEAD) \ | ||||
|             --build-args COMMIT=$(git rev-parse HEAD) \ | ||||
|       # | ||||
|       # Build Discord Notification | ||||
|       # | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { AdminTaskAPI } from "./admin/admin-tasks"; | ||||
| import { AdminUsersApi } from "./admin/admin-users"; | ||||
| import { AdminGroupsApi } from "./admin/admin-groups"; | ||||
| import { AdminBackupsApi } from "./admin/admin-backups"; | ||||
| import { AdminMaintenanceApi } from "./admin/admin-maintenance"; | ||||
| import { ApiRequestInstance } from "~/types/api"; | ||||
|  | ||||
| export class AdminAPI { | ||||
| @@ -11,6 +12,7 @@ export class AdminAPI { | ||||
|   public users: AdminUsersApi; | ||||
|   public groups: AdminGroupsApi; | ||||
|   public backups: AdminBackupsApi; | ||||
|   public maintenance: AdminMaintenanceApi; | ||||
|  | ||||
|   constructor(requests: ApiRequestInstance) { | ||||
|     this.about = new AdminAboutAPI(requests); | ||||
| @@ -18,6 +20,7 @@ export class AdminAPI { | ||||
|     this.users = new AdminUsersApi(requests); | ||||
|     this.groups = new AdminGroupsApi(requests); | ||||
|     this.backups = new AdminBackupsApi(requests); | ||||
|     this.maintenance = new AdminMaintenanceApi(requests); | ||||
|  | ||||
|     Object.freeze(this); | ||||
|   } | ||||
|   | ||||
							
								
								
									
										30
									
								
								frontend/api/admin/admin-maintenance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/api/admin/admin-maintenance.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import { BaseAPI } from "../_base"; | ||||
| import { SuccessResponse } from "~/types/api-types/response"; | ||||
| import { MaintenanceSummary } from "~/types/api-types/admin"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| const routes = { | ||||
|   base: `${prefix}/admin/maintenance`, | ||||
|   cleanImages: `${prefix}/admin/maintenance/clean/images`, | ||||
|   cleanRecipeFolders: `${prefix}/admin/maintenance/clean/recipe-folders`, | ||||
|   cleanLogFile: `${prefix}/admin/maintenance/clean/logs`, | ||||
| }; | ||||
|  | ||||
| export class AdminMaintenanceApi extends BaseAPI { | ||||
|   async getInfo() { | ||||
|     return this.requests.get<MaintenanceSummary>(routes.base); | ||||
|   } | ||||
|  | ||||
|   async cleanImages() { | ||||
|     return await this.requests.post<SuccessResponse>(routes.cleanImages, {}); | ||||
|   } | ||||
|  | ||||
|   async cleanRecipeFolders() { | ||||
|     return await this.requests.post<SuccessResponse>(routes.cleanRecipeFolders, {}); | ||||
|   } | ||||
|  | ||||
|   async cleanLogFile() { | ||||
|     return await this.requests.post<SuccessResponse>(routes.cleanLogFile, {}); | ||||
|   } | ||||
| } | ||||
| @@ -54,6 +54,11 @@ export default defineComponent({ | ||||
|         to: "/admin/site-settings", | ||||
|         title: i18n.t("sidebar.site-settings"), | ||||
|       }, | ||||
|       { | ||||
|         icon: $globals.icons.cog, | ||||
|         to: "/admin/maintenance", | ||||
|         title: "Maintenance", | ||||
|       }, | ||||
|       { | ||||
|         icon: $globals.icons.user, | ||||
|         to: "/admin/manage/users", | ||||
|   | ||||
							
								
								
									
										165
									
								
								frontend/pages/admin/maintenance.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								frontend/pages/admin/maintenance.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | ||||
| <template> | ||||
|   <v-container fluid class="narrow-container"> | ||||
|     <BasePageTitle divider> | ||||
|       <template #title> Site Maintenance </template> | ||||
|     </BasePageTitle> | ||||
|  | ||||
|     <BannerExperimental /> | ||||
|  | ||||
|     <section> | ||||
|       <BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="Summary"> </BaseCardSectionTitle> | ||||
|       <div class="mb-6 ml-2"> | ||||
|         <BaseButton color="info" @click="getSummary"> | ||||
|           <template #icon> {{ $globals.icons.tools }} </template> | ||||
|           Get Summary | ||||
|         </BaseButton> | ||||
|       </div> | ||||
|       <v-card class="ma-2" :loading="state.fetchingInfo"> | ||||
|         <template v-for="(value, idx) in info"> | ||||
|           <v-list-item :key="`item-${idx}`"> | ||||
|             <v-list-item-title> | ||||
|               <div>{{ value.name }}</div> | ||||
|             </v-list-item-title> | ||||
|             <v-list-item-subtitle class="text-end"> {{ value.value }} </v-list-item-subtitle> | ||||
|           </v-list-item> | ||||
|           <v-divider :key="`divider-${idx}`" class="mx-2"></v-divider> | ||||
|         </template> | ||||
|       </v-card> | ||||
|     </section> | ||||
|     <section> | ||||
|       <BaseCardSectionTitle class="pb-0 mt-8" :icon="$globals.icons.cog" title="Actions"> | ||||
|         Maintenance actions are <b> destructive </b> and should be used with caution. Performing any of these actions is | ||||
|         <b> irreversible </b>. | ||||
|       </BaseCardSectionTitle> | ||||
|       <v-card class="ma-2" :loading="state.actionLoading"> | ||||
|         <template v-for="(action, idx) in actions"> | ||||
|           <v-list-item :key="`item-${idx}`"> | ||||
|             <v-list-item-title> | ||||
|               <div>{{ action.name }}</div> | ||||
|               <v-list-item-subtitle> | ||||
|                 {{ action.subtitle }} | ||||
|               </v-list-item-subtitle> | ||||
|             </v-list-item-title> | ||||
|             <v-list-item-action> | ||||
|               <BaseButton color="info" @click="action.handler"> | ||||
|                 <template #icon> {{ $globals.icons.robot }}</template> | ||||
|                 Run | ||||
|               </BaseButton> | ||||
|             </v-list-item-action> | ||||
|           </v-list-item> | ||||
|           <v-divider :key="`divider-${idx}`" class="mx-2"></v-divider> | ||||
|         </template> | ||||
|       </v-card> | ||||
|     </section> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { computed, ref, defineComponent, reactive } from "@nuxtjs/composition-api"; | ||||
| import { useAdminApi } from "~/composables/api"; | ||||
| import { MaintenanceSummary } from "~/types/api-types/admin"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   layout: "admin", | ||||
|   setup() { | ||||
|     const state = reactive({ | ||||
|       fetchingInfo: false, | ||||
|       actionLoading: false, | ||||
|     }); | ||||
|  | ||||
|     const adminApi = useAdminApi(); | ||||
|  | ||||
|     const infoResults = ref<MaintenanceSummary>({ | ||||
|       dataDirSize: "unknown", | ||||
|       logFileSize: "unknown", | ||||
|       cleanableDirs: 0, | ||||
|       cleanableImages: 0, | ||||
|     }); | ||||
|  | ||||
|     async function getSummary() { | ||||
|       state.fetchingInfo = true; | ||||
|       const { data } = await adminApi.maintenance.getInfo(); | ||||
|  | ||||
|       infoResults.value = data ?? { | ||||
|         dataDirSize: "unknown", | ||||
|         logFileSize: "unknown", | ||||
|         cleanableDirs: 0, | ||||
|         cleanableImages: 0, | ||||
|       }; | ||||
|  | ||||
|       state.fetchingInfo = false; | ||||
|     } | ||||
|  | ||||
|     const info = computed(() => { | ||||
|       return [ | ||||
|         { | ||||
|           name: "Data Directory Size", | ||||
|           value: infoResults.value.dataDirSize, | ||||
|         }, | ||||
|         { | ||||
|           name: "Log File Size", | ||||
|           value: infoResults.value.logFileSize, | ||||
|         }, | ||||
|         { | ||||
|           name: "Cleanable Directories", | ||||
|           value: infoResults.value.cleanableDirs, | ||||
|         }, | ||||
|         { | ||||
|           name: "Cleanable Images", | ||||
|           value: infoResults.value.cleanableImages, | ||||
|         }, | ||||
|       ]; | ||||
|     }); | ||||
|  | ||||
|     async function handleDeleteLogFile() { | ||||
|       state.actionLoading = true; | ||||
|       await adminApi.maintenance.cleanLogFile(); | ||||
|       state.actionLoading = false; | ||||
|     } | ||||
|  | ||||
|     async function handleCleanDirectories() { | ||||
|       state.actionLoading = true; | ||||
|       await adminApi.maintenance.cleanRecipeFolders(); | ||||
|       state.actionLoading = false; | ||||
|     } | ||||
|  | ||||
|     async function handleCleanImages() { | ||||
|       state.actionLoading = true; | ||||
|       await adminApi.maintenance.cleanImages(); | ||||
|       state.actionLoading = false; | ||||
|     } | ||||
|  | ||||
|     const actions = [ | ||||
|       { | ||||
|         name: "Delete Log Files", | ||||
|         handler: handleDeleteLogFile, | ||||
|         subtitle: "Deletes all the log files", | ||||
|       }, | ||||
|       { | ||||
|         name: "Clean Directories", | ||||
|         handler: handleCleanDirectories, | ||||
|         subtitle: "Removes all the recipe folders that are not valid UUIDs", | ||||
|       }, | ||||
|       { | ||||
|         name: "Clean Images", | ||||
|         handler: handleCleanImages, | ||||
|         subtitle: "Removes all the images that don't end with .webp", | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     return { | ||||
|       state, | ||||
|       info, | ||||
|       getSummary, | ||||
|       actions, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|     return { | ||||
|       title: this.$t("settings.site-settings") as string, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped></style> | ||||
| @@ -190,6 +190,12 @@ export interface ImportJob { | ||||
|   force?: boolean; | ||||
|   rebase?: boolean; | ||||
| } | ||||
| export interface MaintenanceSummary { | ||||
|   dataDirSize: string; | ||||
|   logFileSize: string; | ||||
|   cleanableImages: number; | ||||
|   cleanableDirs: number; | ||||
| } | ||||
| export interface MigrationFile { | ||||
|   name: string; | ||||
|   date: string; | ||||
|   | ||||
							
								
								
									
										28
									
								
								mealie/pkgs/img/static.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								mealie/pkgs/img/static.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| NOT_WEBP = { | ||||
|     ".jpg", | ||||
|     ".jpeg", | ||||
|     ".jpe", | ||||
|     ".jif", | ||||
|     ".jfif", | ||||
|     ".jfi", | ||||
|     ".png", | ||||
|     ".gif", | ||||
|     ".tiff", | ||||
|     ".tif", | ||||
|     ".psd", | ||||
|     ".raw", | ||||
|     ".arw", | ||||
|     ".cr2", | ||||
|     ".nrw", | ||||
|     ".k25", | ||||
|     ".bmp", | ||||
|     ".dib", | ||||
|     ".heif", | ||||
|     ".heic", | ||||
|     ".ind", | ||||
|     ".jp2", | ||||
|     ".svg", | ||||
|     ".svgz", | ||||
|     ".ai", | ||||
|     ".eps", | ||||
| } | ||||
| @@ -1,3 +1,7 @@ | ||||
| import os | ||||
| from pathlib import Path | ||||
|  | ||||
|  | ||||
| def pretty_size(size: int) -> str: | ||||
|     """ | ||||
|     Pretty size takes in a integer value of a file size and returns the most applicable | ||||
| @@ -13,3 +17,17 @@ def pretty_size(size: int) -> str: | ||||
|         return f"{round(size / 1024 / 1024 / 1024, 2)} GB" | ||||
|     else: | ||||
|         return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB" | ||||
|  | ||||
|  | ||||
| def get_dir_size(path: Path | str) -> int: | ||||
|     """ | ||||
|     Get the size of a directory | ||||
|     """ | ||||
|     total_size = os.path.getsize(path) | ||||
|     for item in os.listdir(path): | ||||
|         itempath = os.path.join(path, item) | ||||
|         if os.path.isfile(itempath): | ||||
|             total_size += os.path.getsize(itempath) | ||||
|         elif os.path.isdir(itempath): | ||||
|             total_size += get_dir_size(itempath) | ||||
|     return total_size | ||||
|   | ||||
| @@ -5,6 +5,7 @@ from . import ( | ||||
|     admin_backups, | ||||
|     admin_email, | ||||
|     admin_log, | ||||
|     admin_maintenance, | ||||
|     admin_management_groups, | ||||
|     admin_management_users, | ||||
|     admin_server_tasks, | ||||
| @@ -18,4 +19,5 @@ router.include_router(admin_management_users.router) | ||||
| router.include_router(admin_management_groups.router) | ||||
| router.include_router(admin_email.router, tags=["Admin: Email"]) | ||||
| router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"]) | ||||
| router.include_router(admin_backups.router) | ||||
| router.include_router(admin_backups.router, tags=["Admin: Backups"]) | ||||
| router.include_router(admin_maintenance.router, tags=["Admin: Maintenance"]) | ||||
|   | ||||
							
								
								
									
										108
									
								
								mealie/routes/admin/admin_maintenance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								mealie/routes/admin/admin_maintenance.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| import contextlib | ||||
| import os | ||||
| import shutil | ||||
| import uuid | ||||
| from pathlib import Path | ||||
|  | ||||
| from fastapi import APIRouter, HTTPException | ||||
|  | ||||
| from mealie.core.root_logger import LOGGER_FILE | ||||
| from mealie.pkgs.stats import fs_stats | ||||
| from mealie.routes._base import BaseAdminController, controller | ||||
| from mealie.schema.admin import MaintenanceSummary | ||||
| from mealie.schema.response import ErrorResponse, SuccessResponse | ||||
|  | ||||
| router = APIRouter(prefix="/maintenance") | ||||
|  | ||||
|  | ||||
| def clean_images(root_dir: Path, dry_run: bool) -> int: | ||||
|     cleaned_images = 0 | ||||
|  | ||||
|     for recipe_dir in root_dir.iterdir(): | ||||
|         image_dir = recipe_dir.joinpath("images") | ||||
|  | ||||
|         if not image_dir.exists(): | ||||
|             continue | ||||
|  | ||||
|         for image in image_dir.iterdir(): | ||||
|             if image.is_dir(): | ||||
|                 continue | ||||
|  | ||||
|             if image.suffix != ".webp": | ||||
|                 if not dry_run: | ||||
|                     image.unlink() | ||||
|  | ||||
|                 cleaned_images += 1 | ||||
|  | ||||
|     return cleaned_images | ||||
|  | ||||
|  | ||||
| def clean_recipe_folders(root_dir: Path, dry_run: bool) -> int: | ||||
|     cleaned_dirs = 0 | ||||
|  | ||||
|     for recipe_dir in root_dir.iterdir(): | ||||
|         if recipe_dir.is_dir(): | ||||
|             # Attemp to convert the folder name to a UUID | ||||
|             try: | ||||
|                 uuid.UUID(recipe_dir.name) | ||||
|                 continue | ||||
|             except ValueError: | ||||
|                 if not dry_run: | ||||
|                     shutil.rmtree(recipe_dir) | ||||
|                 cleaned_dirs += 1 | ||||
|  | ||||
|     return cleaned_dirs | ||||
|  | ||||
|  | ||||
| @controller(router) | ||||
| class AdminMaintenanceController(BaseAdminController): | ||||
|     @router.get("", response_model=MaintenanceSummary) | ||||
|     def get_maintenance_summary(self): | ||||
|         """ | ||||
|         Get the maintenance summary | ||||
|         """ | ||||
|         log_file_size = 0 | ||||
|         with contextlib.suppress(FileNotFoundError): | ||||
|             log_file_size = os.path.getsize(LOGGER_FILE) | ||||
|  | ||||
|         return MaintenanceSummary( | ||||
|             data_dir_size=fs_stats.pretty_size(fs_stats.get_dir_size(self.deps.folders.DATA_DIR)), | ||||
|             log_file_size=fs_stats.pretty_size(log_file_size), | ||||
|             cleanable_images=clean_images(self.deps.folders.RECIPE_DATA_DIR, dry_run=True), | ||||
|             cleanable_dirs=clean_recipe_folders(self.deps.folders.RECIPE_DATA_DIR, dry_run=True), | ||||
|         ) | ||||
|  | ||||
|     @router.post("/clean/images", response_model=SuccessResponse) | ||||
|     def clean_images(self): | ||||
|         """ | ||||
|         Purges all the images from the filesystem that aren't .webp | ||||
|         """ | ||||
|         try: | ||||
|             cleaned_images = clean_images(self.deps.folders.RECIPE_DATA_DIR, dry_run=False) | ||||
|             return SuccessResponse.respond(f"{cleaned_images} Images cleaned") | ||||
|         except Exception as e: | ||||
|             raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean images")) from e | ||||
|  | ||||
|     @router.post("/clean/recipe-folders", response_model=SuccessResponse) | ||||
|     def clean_recipe_folders(self): | ||||
|         """ | ||||
|         Deletes all the recipe folders that don't have names that are valid UUIDs | ||||
|         """ | ||||
|         try: | ||||
|             cleaned_dirs = clean_recipe_folders(self.deps.folders.RECIPE_DATA_DIR, dry_run=False) | ||||
|             return SuccessResponse.respond(f"{cleaned_dirs} Recipe folders removed") | ||||
|         except Exception as e: | ||||
|             raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean directories")) from e | ||||
|  | ||||
|     @router.post("/clean/logs", response_model=SuccessResponse) | ||||
|     def clean_logs(self): | ||||
|         """ | ||||
|         Purges the logs | ||||
|         """ | ||||
|         try: | ||||
|             with contextlib.suppress(FileNotFoundError): | ||||
|                 os.remove(LOGGER_FILE) | ||||
|                 LOGGER_FILE.touch() | ||||
|             return SuccessResponse.respond("Logs cleaned") | ||||
|         except Exception as e: | ||||
|             raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean logs")) from e | ||||
| @@ -1,6 +1,7 @@ | ||||
| # GENERATED CODE - DO NOT MODIFY BY HAND | ||||
| from .about import * | ||||
| from .backup import * | ||||
| from .maintenance import * | ||||
| from .migration import * | ||||
| from .restore import * | ||||
| from .settings import * | ||||
|   | ||||
							
								
								
									
										8
									
								
								mealie/schema/admin/maintenance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								mealie/schema/admin/maintenance.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| from fastapi_camelcase import CamelModel | ||||
|  | ||||
|  | ||||
| class MaintenanceSummary(CamelModel): | ||||
|     data_dir_size: str | ||||
|     log_file_size: str | ||||
|     cleanable_images: int | ||||
|     cleanable_dirs: int | ||||
		Reference in New Issue
	
	Block a user