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 \
|
docker build --push --no-cache \
|
||||||
--tag hkotel/mealie:api-nightly \
|
--tag hkotel/mealie:api-nightly \
|
||||||
--platform linux/amd64,linux/arm64 .
|
--platform linux/amd64,linux/arm64 .
|
||||||
--build-arg COMMIT=$(git rev-parse HEAD) \
|
--build-args COMMIT=$(git rev-parse HEAD) \
|
||||||
#
|
#
|
||||||
# Build Discord Notification
|
# Build Discord Notification
|
||||||
#
|
#
|
||||||
|
@ -3,6 +3,7 @@ import { AdminTaskAPI } from "./admin/admin-tasks";
|
|||||||
import { AdminUsersApi } from "./admin/admin-users";
|
import { AdminUsersApi } from "./admin/admin-users";
|
||||||
import { AdminGroupsApi } from "./admin/admin-groups";
|
import { AdminGroupsApi } from "./admin/admin-groups";
|
||||||
import { AdminBackupsApi } from "./admin/admin-backups";
|
import { AdminBackupsApi } from "./admin/admin-backups";
|
||||||
|
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
|
||||||
import { ApiRequestInstance } from "~/types/api";
|
import { ApiRequestInstance } from "~/types/api";
|
||||||
|
|
||||||
export class AdminAPI {
|
export class AdminAPI {
|
||||||
@ -11,6 +12,7 @@ export class AdminAPI {
|
|||||||
public users: AdminUsersApi;
|
public users: AdminUsersApi;
|
||||||
public groups: AdminGroupsApi;
|
public groups: AdminGroupsApi;
|
||||||
public backups: AdminBackupsApi;
|
public backups: AdminBackupsApi;
|
||||||
|
public maintenance: AdminMaintenanceApi;
|
||||||
|
|
||||||
constructor(requests: ApiRequestInstance) {
|
constructor(requests: ApiRequestInstance) {
|
||||||
this.about = new AdminAboutAPI(requests);
|
this.about = new AdminAboutAPI(requests);
|
||||||
@ -18,6 +20,7 @@ export class AdminAPI {
|
|||||||
this.users = new AdminUsersApi(requests);
|
this.users = new AdminUsersApi(requests);
|
||||||
this.groups = new AdminGroupsApi(requests);
|
this.groups = new AdminGroupsApi(requests);
|
||||||
this.backups = new AdminBackupsApi(requests);
|
this.backups = new AdminBackupsApi(requests);
|
||||||
|
this.maintenance = new AdminMaintenanceApi(requests);
|
||||||
|
|
||||||
Object.freeze(this);
|
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",
|
to: "/admin/site-settings",
|
||||||
title: i18n.t("sidebar.site-settings"),
|
title: i18n.t("sidebar.site-settings"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.cog,
|
||||||
|
to: "/admin/maintenance",
|
||||||
|
title: "Maintenance",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.user,
|
icon: $globals.icons.user,
|
||||||
to: "/admin/manage/users",
|
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;
|
force?: boolean;
|
||||||
rebase?: boolean;
|
rebase?: boolean;
|
||||||
}
|
}
|
||||||
|
export interface MaintenanceSummary {
|
||||||
|
dataDirSize: string;
|
||||||
|
logFileSize: string;
|
||||||
|
cleanableImages: number;
|
||||||
|
cleanableDirs: number;
|
||||||
|
}
|
||||||
export interface MigrationFile {
|
export interface MigrationFile {
|
||||||
name: string;
|
name: string;
|
||||||
date: 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:
|
def pretty_size(size: int) -> str:
|
||||||
"""
|
"""
|
||||||
Pretty size takes in a integer value of a file size and returns the most applicable
|
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"
|
return f"{round(size / 1024 / 1024 / 1024, 2)} GB"
|
||||||
else:
|
else:
|
||||||
return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB"
|
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_backups,
|
||||||
admin_email,
|
admin_email,
|
||||||
admin_log,
|
admin_log,
|
||||||
|
admin_maintenance,
|
||||||
admin_management_groups,
|
admin_management_groups,
|
||||||
admin_management_users,
|
admin_management_users,
|
||||||
admin_server_tasks,
|
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_management_groups.router)
|
||||||
router.include_router(admin_email.router, tags=["Admin: Email"])
|
router.include_router(admin_email.router, tags=["Admin: Email"])
|
||||||
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])
|
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
|
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
from .about import *
|
from .about import *
|
||||||
from .backup import *
|
from .backup import *
|
||||||
|
from .maintenance import *
|
||||||
from .migration import *
|
from .migration import *
|
||||||
from .restore import *
|
from .restore import *
|
||||||
from .settings 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