mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -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:
parent
ffb3b45ac2
commit
4ef649231b
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
|
Loading…
x
Reference in New Issue
Block a user