From 4ef649231ba1ad3ebb0af1e15f4a969a854502ce Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Thu, 24 Mar 2022 22:17:38 -0800 Subject: [PATCH] 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 --- .github/workflows/backend-docker-nightly.yml | 2 +- frontend/api/admin-api.ts | 3 + frontend/api/admin/admin-maintenance.ts | 30 ++++ frontend/layouts/admin.vue | 5 + frontend/pages/admin/maintenance.vue | 165 +++++++++++++++++++ frontend/types/api-types/admin.ts | 6 + mealie/pkgs/img/static.py | 28 ++++ mealie/pkgs/stats/fs_stats.py | 18 ++ mealie/routes/admin/__init__.py | 4 +- mealie/routes/admin/admin_maintenance.py | 108 ++++++++++++ mealie/schema/admin/__init__.py | 1 + mealie/schema/admin/maintenance.py | 8 + 12 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 frontend/api/admin/admin-maintenance.ts create mode 100644 frontend/pages/admin/maintenance.vue create mode 100644 mealie/pkgs/img/static.py create mode 100644 mealie/routes/admin/admin_maintenance.py create mode 100644 mealie/schema/admin/maintenance.py diff --git a/.github/workflows/backend-docker-nightly.yml b/.github/workflows/backend-docker-nightly.yml index 4794a08e7055..0935e168386c 100644 --- a/.github/workflows/backend-docker-nightly.yml +++ b/.github/workflows/backend-docker-nightly.yml @@ -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 # diff --git a/frontend/api/admin-api.ts b/frontend/api/admin-api.ts index 1b560634abfb..b50450e4b8d2 100644 --- a/frontend/api/admin-api.ts +++ b/frontend/api/admin-api.ts @@ -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); } diff --git a/frontend/api/admin/admin-maintenance.ts b/frontend/api/admin/admin-maintenance.ts new file mode 100644 index 000000000000..9b286ec2f2bf --- /dev/null +++ b/frontend/api/admin/admin-maintenance.ts @@ -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(routes.base); + } + + async cleanImages() { + return await this.requests.post(routes.cleanImages, {}); + } + + async cleanRecipeFolders() { + return await this.requests.post(routes.cleanRecipeFolders, {}); + } + + async cleanLogFile() { + return await this.requests.post(routes.cleanLogFile, {}); + } +} diff --git a/frontend/layouts/admin.vue b/frontend/layouts/admin.vue index 195462aafa0d..7affb7799fc8 100644 --- a/frontend/layouts/admin.vue +++ b/frontend/layouts/admin.vue @@ -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", diff --git a/frontend/pages/admin/maintenance.vue b/frontend/pages/admin/maintenance.vue new file mode 100644 index 000000000000..999f688ed3ac --- /dev/null +++ b/frontend/pages/admin/maintenance.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/frontend/types/api-types/admin.ts b/frontend/types/api-types/admin.ts index 0071cacc7784..8043889e8dc6 100644 --- a/frontend/types/api-types/admin.ts +++ b/frontend/types/api-types/admin.ts @@ -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; diff --git a/mealie/pkgs/img/static.py b/mealie/pkgs/img/static.py new file mode 100644 index 000000000000..1d34e085bf56 --- /dev/null +++ b/mealie/pkgs/img/static.py @@ -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", +} diff --git a/mealie/pkgs/stats/fs_stats.py b/mealie/pkgs/stats/fs_stats.py index b114db776207..77ef2c0d0102 100644 --- a/mealie/pkgs/stats/fs_stats.py +++ b/mealie/pkgs/stats/fs_stats.py @@ -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 diff --git a/mealie/routes/admin/__init__.py b/mealie/routes/admin/__init__.py index beb807fe935a..b6460d6ebedd 100644 --- a/mealie/routes/admin/__init__.py +++ b/mealie/routes/admin/__init__.py @@ -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"]) diff --git a/mealie/routes/admin/admin_maintenance.py b/mealie/routes/admin/admin_maintenance.py new file mode 100644 index 000000000000..5e0e4f3fa12a --- /dev/null +++ b/mealie/routes/admin/admin_maintenance.py @@ -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 diff --git a/mealie/schema/admin/__init__.py b/mealie/schema/admin/__init__.py index 12dea356331c..7587c29e22ed 100644 --- a/mealie/schema/admin/__init__.py +++ b/mealie/schema/admin/__init__.py @@ -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 * diff --git a/mealie/schema/admin/maintenance.py b/mealie/schema/admin/maintenance.py new file mode 100644 index 000000000000..38ffc8433e55 --- /dev/null +++ b/mealie/schema/admin/maintenance.py @@ -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